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.
- package/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +10 -8
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +201 -17
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +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
|
|
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
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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({
|
|
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
|
]
|