typeclaw 0.8.0 → 0.9.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.
Files changed (92) hide show
  1. package/README.md +6 -6
  2. package/package.json +5 -3
  3. package/scripts/require-parallel.ts +41 -0
  4. package/src/agent/index.ts +55 -6
  5. package/src/agent/live-sessions.ts +34 -0
  6. package/src/agent/plugin-tools.ts +2 -0
  7. package/src/agent/session-meta.ts +21 -2
  8. package/src/agent/subagent-completion-reminder.ts +89 -0
  9. package/src/agent/subagents.ts +3 -2
  10. package/src/agent/system-prompt.ts +10 -8
  11. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  12. package/src/bundled-plugins/guard/index.ts +14 -1
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  14. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  15. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  16. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  17. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  18. package/src/bundled-plugins/guard/policy.ts +7 -0
  19. package/src/bundled-plugins/memory/README.md +76 -62
  20. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  21. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  22. package/src/bundled-plugins/memory/citations.ts +19 -8
  23. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  24. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  25. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  26. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  27. package/src/bundled-plugins/memory/index.ts +236 -16
  28. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  29. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  30. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  31. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  32. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  33. package/src/bundled-plugins/memory/migration.ts +282 -1
  34. package/src/bundled-plugins/memory/paths.ts +42 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  36. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  37. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  38. package/src/bundled-plugins/memory/slug.ts +59 -0
  39. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  40. package/src/bundled-plugins/memory/strength.ts +3 -3
  41. package/src/bundled-plugins/memory/topics.ts +70 -16
  42. package/src/bundled-plugins/security/index.ts +24 -0
  43. package/src/bundled-plugins/security/permissions.ts +4 -0
  44. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  45. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  46. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  47. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  48. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  49. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  50. package/src/channels/adapters/kakaotalk.ts +64 -37
  51. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  52. package/src/channels/index.ts +5 -0
  53. package/src/channels/router.ts +201 -17
  54. package/src/channels/subagent-completion-bridge.ts +84 -0
  55. package/src/cli/builtins.ts +1 -0
  56. package/src/cli/index.ts +1 -0
  57. package/src/cli/init.ts +122 -14
  58. package/src/cli/inspect.ts +151 -0
  59. package/src/cron/consumer.ts +1 -1
  60. package/src/init/dockerfile.ts +268 -4
  61. package/src/init/hatching.ts +5 -6
  62. package/src/init/kakaotalk-auth.ts +6 -47
  63. package/src/init/validate-api-key.ts +121 -0
  64. package/src/inspect/index.ts +213 -0
  65. package/src/inspect/label.ts +50 -0
  66. package/src/inspect/live.ts +221 -0
  67. package/src/inspect/render.ts +163 -0
  68. package/src/inspect/replay.ts +265 -0
  69. package/src/inspect/session-list.ts +160 -0
  70. package/src/inspect/types.ts +110 -0
  71. package/src/plugin/hooks.ts +23 -1
  72. package/src/plugin/index.ts +2 -0
  73. package/src/plugin/manager.ts +1 -1
  74. package/src/plugin/registry.ts +1 -1
  75. package/src/plugin/types.ts +10 -0
  76. package/src/run/channel-session-factory.ts +7 -1
  77. package/src/run/index.ts +87 -21
  78. package/src/secrets/kakao-renewal.ts +3 -47
  79. package/src/server/index.ts +241 -60
  80. package/src/shared/index.ts +3 -0
  81. package/src/shared/protocol.ts +49 -0
  82. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  83. package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
  84. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  85. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  86. package/src/skills/typeclaw-config/SKILL.md +1 -1
  87. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  88. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  89. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  90. package/src/skills/typeclaw-plugins/SKILL.md +25 -14
  91. package/src/test-helpers/wait-for.ts +7 -1
  92. package/typeclaw.schema.json +7 -0
@@ -0,0 +1,59 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/
4
+
5
+ export function isValidSlug(slug: string): boolean {
6
+ return SLUG_REGEX.test(slug)
7
+ }
8
+
9
+ export function headingToSlug(heading: string, existingSlugs: Set<string>): string {
10
+ let slug = normalizeHeading(heading)
11
+
12
+ if (slug.length === 0) {
13
+ slug = makeUntitledSlug(heading)
14
+ }
15
+
16
+ slug = slug.slice(0, 64)
17
+
18
+ slug = deduplicateSlug(slug, existingSlugs)
19
+
20
+ return slug
21
+ }
22
+
23
+ function normalizeHeading(heading: string): string {
24
+ let normalized = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
25
+
26
+ normalized = normalized
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, '-')
29
+ .replace(/^-+|-+$/g, '')
30
+
31
+ return normalized
32
+ }
33
+
34
+ function makeUntitledSlug(heading: string): string {
35
+ const hash = createHash('sha256').update(heading).digest('hex').slice(0, 6)
36
+ return `untitled-${hash}`
37
+ }
38
+
39
+ function deduplicateSlug(slug: string, existingSlugs: Set<string>): string {
40
+ const lowerSlug = slug.toLowerCase()
41
+ let candidate = lowerSlug
42
+ let suffix = 2
43
+
44
+ while (isTaken(candidate, existingSlugs)) {
45
+ candidate = `${lowerSlug}-${suffix}`
46
+ suffix++
47
+ }
48
+
49
+ return candidate
50
+ }
51
+
52
+ function isTaken(candidate: string, existingSlugs: Set<string>): boolean {
53
+ for (const existing of existingSlugs) {
54
+ if (existing.toLowerCase() === candidate) {
55
+ return true
56
+ }
57
+ }
58
+ return false
59
+ }
@@ -1,7 +1,13 @@
1
- import { readFile, appendFile, writeFile, rename } from 'node:fs/promises'
1
+ import { readFile, appendFile, readdir, writeFile, rename } from 'node:fs/promises'
2
+ import { join } from 'node:path'
2
3
 
4
+ import { getDreamedIds, loadDreamingState } from './dreaming-state'
5
+ import { streamsDir } from './paths'
3
6
  import { parseEventLine, type StreamEvent } from './stream-events'
4
7
 
8
+ const STREAM_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/
9
+ const STREAM_DATE_FROM_FILENAME = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
10
+
5
11
  export async function readEvents(path: string): Promise<StreamEvent[]> {
6
12
  let raw: string
7
13
  try {
@@ -41,6 +47,109 @@ export async function writeEventsAtomic(path: string, events: readonly StreamEve
41
47
  await rename(tmp, path)
42
48
  }
43
49
 
50
+ // Daily-stream directory for this agent. New layout is `memory/streams/`;
51
+ // pre-migration agents kept them flat under `memory/`. `displayPrefix` is
52
+ // the path string consumers render so the agent sees a stable identifier
53
+ // regardless of which layout is on disk.
54
+ export type StreamDirectory = {
55
+ dir: string
56
+ displayPrefix: 'memory' | 'memory/streams'
57
+ names: string[]
58
+ }
59
+
60
+ export async function listStreamFiles(agentDir: string): Promise<StreamDirectory | null> {
61
+ const streamsDirPath = streamsDir(agentDir)
62
+ try {
63
+ const names = await readdir(streamsDirPath)
64
+ return { dir: streamsDirPath, displayPrefix: 'memory/streams', names }
65
+ } catch (err) {
66
+ if (!isEnoent(err)) throw err
67
+ }
68
+
69
+ const legacyDir = join(agentDir, 'memory')
70
+ try {
71
+ const names = await readdir(legacyDir)
72
+ return { dir: legacyDir, displayPrefix: 'memory', names }
73
+ } catch (err) {
74
+ if (!isEnoent(err)) throw err
75
+ return null
76
+ }
77
+ }
78
+
79
+ // Per-file slice with dreamed events removed. `events` is whatever
80
+ // `readEvents` returned for the file; `dreamedIds` is the day's slice from
81
+ // `getDreamedIds(state, date)`. Returns the events the next consumer should
82
+ // see — empty when every event has been dreamed.
83
+ //
84
+ // `legacy_prose` events pre-date the dreamed-id contract (they have no `id`)
85
+ // and are always kept. Same rule as the injection-side filter; lifted here
86
+ // so injection and search agree on what counts as undreamed.
87
+ export function filterUndreamedEvents(events: StreamEvent[], dreamedIds: ReadonlySet<string>): StreamEvent[] {
88
+ if (dreamedIds.size === 0) return events
89
+ return events.filter((event) => {
90
+ if (event.type === 'legacy_prose') return true
91
+ return !dreamedIds.has(event.id)
92
+ })
93
+ }
94
+
95
+ // Raw events + per-day dreamed-id set, oldest day first. The dreamed filter
96
+ // is applied per-day by `readAllUndreamedStreamDays` — keeping it separate
97
+ // here lets callers that need unfiltered events read dreaming state once
98
+ // rather than re-loading it for every day.
99
+ export type StreamDay = {
100
+ date: string
101
+ path: string
102
+ name: string
103
+ events: StreamEvent[]
104
+ dreamedIds: ReadonlySet<string>
105
+ }
106
+
107
+ export async function readAllStreamDays(agentDir: string): Promise<StreamDay[]> {
108
+ const streamFiles = await listStreamFiles(agentDir)
109
+ if (streamFiles === null) return []
110
+
111
+ const { dir, displayPrefix, names } = streamFiles
112
+ const dated = names.filter((n) => STREAM_FILE_PATTERN.test(n)).sort()
113
+ const state = await loadDreamingState(agentDir)
114
+ return Promise.all(
115
+ dated.map(async (name): Promise<StreamDay> => {
116
+ const date = STREAM_DATE_FROM_FILENAME.exec(name)?.[1] ?? ''
117
+ const filePath = join(dir, name)
118
+ return {
119
+ date,
120
+ path: filePath,
121
+ name: `${displayPrefix}/${name}`,
122
+ events: await readEvents(filePath),
123
+ dreamedIds: getDreamedIds(state, date),
124
+ }
125
+ }),
126
+ )
127
+ }
128
+
129
+ // Convenience wrapper for consumers that just want undreamed events without
130
+ // caring about filter ordering: pre-applies `filterUndreamedEvents` per day
131
+ // and drops fully-dreamed days. The injection path uses `readAllStreamDays`
132
+ // instead because it must order self-session and dreamed-id filters.
133
+ export type UndreamedStreamDay = {
134
+ date: string
135
+ path: string
136
+ name: string
137
+ events: StreamEvent[]
138
+ }
139
+
140
+ export async function readAllUndreamedStreamDays(agentDir: string): Promise<UndreamedStreamDay[]> {
141
+ const days = await readAllStreamDays(agentDir)
142
+ return days.flatMap((day) => {
143
+ const undreamed = filterUndreamedEvents(day.events, day.dreamedIds)
144
+ if (undreamed.length === 0) return []
145
+ return [{ date: day.date, path: day.path, name: day.name, events: undreamed }]
146
+ })
147
+ }
148
+
149
+ function isEnoent(err: unknown): boolean {
150
+ return typeof err === 'object' && err !== null && 'code' in err && (err as { code: string }).code === 'ENOENT'
151
+ }
152
+
44
153
  export async function countEvents(path: string): Promise<number> {
45
154
  let raw: string
46
155
  try {
@@ -1,4 +1,4 @@
1
- // Strength signals for MEMORY.md topics, derived mechanically from citations.
1
+ // Strength signals for topic shards, derived mechanically from citations.
2
2
  //
3
3
  // What "strength" means here is structural, not semantic — we measure how
4
4
  // many times and over how many distinct days a topic has been reinforced by
@@ -12,7 +12,7 @@
12
12
  // the dreaming subagent's prompt is gated on distinct-days, not count, for
13
13
  // exactly this reason — see the "spacing effect" note in the PR description.
14
14
  //
15
- // All numbers here are deterministic. The same MEMORY.md parsed against the
15
+ // All numbers here are deterministic. The same topic text parsed against the
16
16
  // same `today` always yields the same TopicStrength list. There is no LLM
17
17
  // involvement at this layer; the subagent receives these numbers as ground
18
18
  // truth and uses them to decide what to merge or demote.
@@ -72,7 +72,7 @@ function pickLatestDate(dates: readonly string[]): string | null {
72
72
  // date as midnight UTC, so the difference is always an integer count of
73
73
  // 86_400_000ms windows regardless of timezone or DST. Returns 0 for invalid
74
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
75
+ // because both inputs are produced by the runtime, but a corrupted topic-shard
76
76
  // citation date is the kind of thing we want to fail open on).
77
77
  function daysBetween(today: string, earlier: string): number {
78
78
  const todayMs = parseIsoDateUtc(today)
@@ -1,10 +1,7 @@
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.
1
+ // Topic-aware parser for the pre-shard root-memory migration and legacy strength
2
+ // tests. Sharded runtime memory stores each topic as its own file under
3
+ // memory/topics/, but the one-shot migrator still needs to split a legacy root
4
+ // file into level-2 topic sections before writing shards.
8
5
  //
9
6
  // Format assumptions match what dreaming.ts's DREAMING_SYSTEM_PROMPT teaches:
10
7
  // - First line is `# Memory` (an h1). Treated as a non-topic header.
@@ -17,9 +14,9 @@
17
14
  // aggregation. parseCitations from citations.ts still picks them up if
18
15
  // anything downstream needs the global view.
19
16
  //
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
17
+ // The parser is intentionally permissive: it never throws on malformed legacy
18
+ // topic prose. A header with no body or a topic with no citations still parses
19
+ // cleanly with an empty `citations` array. The
23
20
  // strength layer then treats those topics as "weak" — which is the right
24
21
  // behavior, since they ARE weak.
25
22
 
@@ -40,24 +37,81 @@ export type Topic = {
40
37
  citations: Citation[]
41
38
  }
42
39
 
40
+ export type TopicWithBody = Topic & { body: string }
41
+
43
42
  const HEADING_LEVEL_2 = /^##\s+(.*)$/
43
+ const HISTORICAL_BUCKET = /^historical observations$/i
44
+ const BULLET_LINE = /^-\s+(.*)$/
45
+ const LEADING_DATE = /^\d{4}-\d{2}-\d{2}:\s*/
46
+ const CITATION_TAIL = /\s*—\s+memory\/.*$/
47
+
48
+ function collectCitations(bodyText: string): Citation[] {
49
+ const grouped = parseCitations(bodyText)
50
+ const citations: Citation[] = []
51
+ for (const [date, ids] of grouped) {
52
+ for (const fragmentId of ids) citations.push({ date, fragmentId })
53
+ }
54
+ return citations
55
+ }
44
56
 
45
- // Split MEMORY.md into ordered topics with their citations attached. Returns
57
+ // Split legacy topic prose into ordered topics with their citations attached. Returns
46
58
  // an empty array when no `## ` heading appears.
47
59
  export function parseTopics(text: string): Topic[] {
48
60
  const lines = text.split('\n')
49
61
  const topics: Topic[] = []
50
62
  let current: { heading: string; body: string[] } | undefined
51
63
 
64
+ const flush = (): void => {
65
+ if (!current) return
66
+ const citations = collectCitations(current.body.join('\n'))
67
+ topics.push({ heading: current.heading, citations })
68
+ }
69
+
70
+ for (const line of lines) {
71
+ const match = HEADING_LEVEL_2.exec(line)
72
+ if (match) {
73
+ flush()
74
+ current = { heading: (match[1] ?? '').trim(), body: [] }
75
+ continue
76
+ }
77
+ if (current) current.body.push(line)
78
+ }
79
+ flush()
80
+
81
+ return topics
82
+ }
83
+
84
+ // Like parseTopics, but preserves the full body text of each topic. The
85
+ // `## Historical observations` bucket is expanded into one entry per bullet.
86
+ export function parseTopicsWithBodies(text: string): TopicWithBody[] {
87
+ const lines = text.split('\n')
88
+ const topics: TopicWithBody[] = []
89
+ let current: { heading: string; body: string[] } | undefined
90
+
52
91
  const flush = (): void => {
53
92
  if (!current) return
54
93
  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 })
94
+ if (HISTORICAL_BUCKET.test(current.heading)) {
95
+ let index = 0
96
+ for (const line of current.body) {
97
+ const bulletMatch = BULLET_LINE.exec(line)
98
+ if (!bulletMatch) continue
99
+ const bulletText = bulletMatch[1] ?? ''
100
+ let heading = bulletText.replace(LEADING_DATE, '').replace(CITATION_TAIL, '').trim()
101
+ const citations = collectCitations(bulletText)
102
+ if (!heading) {
103
+ heading = citations[0]?.date ?? `historical-${index}`
104
+ }
105
+ topics.push({ heading, body: bulletText, citations })
106
+ index++
107
+ }
108
+ return
59
109
  }
60
- topics.push({ heading: current.heading, citations })
110
+ topics.push({
111
+ heading: current.heading,
112
+ body: bodyText,
113
+ citations: collectCitations(bodyText),
114
+ })
61
115
  }
62
116
 
63
117
  for (const line of lines) {
@@ -2,6 +2,7 @@ import { definePlugin } from '@/plugin'
2
2
 
3
3
  import { HIGH_TIER_PER_GUARD_PERMISSIONS, SECURITY_PERMISSIONS, SEVERITY_PERMISSION } from './permissions'
4
4
  import type { SecurityPermission, SecuritySeverity } from './permissions'
5
+ import { GUARD_CRON_PROMOTION_SEVERITY, checkCronPromotionGuard } from './policies/cron-promotion'
5
6
  import {
6
7
  GUARD_GIT_EXFIL_SEVERITY,
7
8
  GUARD_GIT_REMOTE_TAINTED_SEVERITY,
@@ -12,6 +13,7 @@ import {
12
13
  import { GUARD_OUTBOUND_SECRET_SEVERITY, checkOutboundSecretGuard } from './policies/outbound-secret-scan'
13
14
  import { applyPromptInjectionDefense } from './policies/prompt-injection'
14
15
  import { clearSessionTaints } from './policies/remote-taint-state'
16
+ import { GUARD_ROLE_PROMOTION_SEVERITY, checkRolePromotionGuard } from './policies/role-promotion'
15
17
  import { GUARD_SECRET_EXFIL_BASH_SEVERITY, checkSecretExfilBashGuard } from './policies/secret-exfil-bash'
16
18
  import { GUARD_SECRET_EXFIL_READ_SEVERITY, checkSecretExfilReadGuard } from './policies/secret-exfil-read'
17
19
  import {
@@ -59,6 +61,10 @@ const BYPASS_ROLE_HINT = {
59
61
  'NOBODY has it by default — high tier requires per-call ack from every role, including owner.',
60
62
  [SECURITY_PERMISSIONS.bypassOutboundSecret]:
61
63
  '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.',
64
+ [SECURITY_PERMISSIONS.bypassRolePromotion]:
65
+ 'NOBODY has it by default — high tier requires per-call ack from every role, including owner. The audience-leak rule generalizes to privilege escalation: even owner running from TUI must not silently rewrite the access-control table on behalf of a channel message.',
66
+ [SECURITY_PERMISSIONS.bypassCronPromotion]:
67
+ 'NOBODY has it by default — high tier requires per-call ack from every role, including owner. Same shape as rolePromotion but deferred: a new cron job (or a changed scheduledByRole) is a privilege grant that fires at schedule-time, and the operator must not silently author one on behalf of a channel message.',
62
68
  } as const satisfies Record<PerGuardSecurityPermission, string>
63
69
 
64
70
  function withPermissionHint(
@@ -93,6 +99,24 @@ export default definePlugin({
93
99
  const canBypass = (severity: SecuritySeverity, perGuardPerm: string): boolean =>
94
100
  can(SEVERITY_PERMISSION[severity]) || can(perGuardPerm)
95
101
 
102
+ const rolePromotionResult = canBypass(GUARD_ROLE_PROMOTION_SEVERITY, SECURITY_PERMISSIONS.bypassRolePromotion)
103
+ ? undefined
104
+ : withPermissionHint(
105
+ await checkRolePromotionGuard({ tool: event.tool, args: event.args, agentDir: ctx.agentDir }),
106
+ SECURITY_PERMISSIONS.bypassRolePromotion,
107
+ GUARD_ROLE_PROMOTION_SEVERITY,
108
+ )
109
+ if (rolePromotionResult) return rolePromotionResult
110
+
111
+ const cronPromotionResult = canBypass(GUARD_CRON_PROMOTION_SEVERITY, SECURITY_PERMISSIONS.bypassCronPromotion)
112
+ ? undefined
113
+ : withPermissionHint(
114
+ await checkCronPromotionGuard({ tool: event.tool, args: event.args, agentDir: ctx.agentDir }),
115
+ SECURITY_PERMISSIONS.bypassCronPromotion,
116
+ GUARD_CRON_PROMOTION_SEVERITY,
117
+ )
118
+ if (cronPromotionResult) return cronPromotionResult
119
+
96
120
  // Taint-recording runs FIRST, independently of the gitExfil guard.
97
121
  // The gitRemoteTainted defense depends on it. We pass through
98
122
  // `permittedBypass` for actors who can skip gitExfil (via either the
@@ -9,6 +9,8 @@ export const SECURITY_PERMISSIONS = {
9
9
  bypassSystemPromptLeak: 'security.bypass.systemPromptLeak',
10
10
  bypassOutboundSecret: 'security.bypass.outboundSecret',
11
11
  bypassGitRemoteTainted: 'security.bypass.gitRemoteTainted',
12
+ bypassRolePromotion: 'security.bypass.rolePromotion',
13
+ bypassCronPromotion: 'security.bypass.cronPromotion',
12
14
  // Severity-tier bypasses. Tiers classify guards on a two-axis policy:
13
15
  // high — bypass sends data to a third-party audience outside the
14
16
  // operator's control loop (channel readers, remote git host).
@@ -45,4 +47,6 @@ export const HIGH_TIER_PER_GUARD_PERMISSIONS: readonly string[] = [
45
47
  SECURITY_PERMISSIONS.bypassGitRemoteTainted,
46
48
  SECURITY_PERMISSIONS.bypassOutboundSecret,
47
49
  SECURITY_PERMISSIONS.bypassSystemPromptLeak,
50
+ SECURITY_PERMISSIONS.bypassRolePromotion,
51
+ SECURITY_PERMISSIONS.bypassCronPromotion,
48
52
  ]