typeclaw 0.3.1 → 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.
Files changed (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
@@ -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,53 +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]: 'only owner has it by default',
29
- [SECURITY_PERMISSIONS.bypassGitRemoteTainted]: 'only owner has it by default',
30
- [SECURITY_PERMISSIONS.bypassSecretExfilRead]: 'only owner has it by default',
31
- [SECURITY_PERMISSIONS.bypassSsrf]: 'only owner has it by default',
32
- [SECURITY_PERMISSIONS.bypassSessionSearchSecrets]: 'only owner has it by default',
33
- [SECURITY_PERMISSIONS.bypassSystemPromptLeak]: 'only owner has it by default',
34
- [SECURITY_PERMISSIONS.bypassOutboundSecret]: 'only owner has it by default',
35
- } as const satisfies Record<SecurityPermission, string>
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.',
53
+ [SECURITY_PERMISSIONS.bypassGitRemoteTainted]:
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>
36
63
 
37
64
  function withPermissionHint(
38
65
  result: SecurityBlock | undefined,
39
- permission: SecurityPermission,
66
+ permission: PerGuardSecurityPermission,
67
+ severity: SecuritySeverity,
40
68
  ): SecurityBlock | undefined {
41
69
  if (!result) return result
42
- const hint = BYPASS_ROLE_HINT[permission]
70
+ const perGuardHint = BYPASS_ROLE_HINT[permission]
71
+ const tierPerm = SEVERITY_PERMISSION[severity]
43
72
  return {
44
73
  block: true,
45
- 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.`,
46
75
  }
47
76
  }
48
77
 
49
78
  export default definePlugin({
50
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],
51
86
  plugin: async (ctx) => ({
52
87
  hooks: {
53
88
  'session.prompt': async (event) => {
@@ -55,68 +90,78 @@ export default definePlugin({
55
90
  },
56
91
  'tool.before': async (event) => {
57
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)
58
95
 
59
96
  // Taint-recording runs FIRST, independently of the gitExfil guard.
60
97
  // The gitRemoteTainted defense depends on it. We pass through
61
- // `permittedBypass` for actors who can skip gitExfil via permission
62
- // so the recorder still fires for them (an acked or
63
- // permission-bypassed command will actually run, so its remote
64
- // 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.
65
102
  recordGitRemoteTaintIfAny({
66
103
  tool: event.tool,
67
104
  args: event.args,
68
105
  sessionId: event.sessionId,
69
- permittedBypass: can(SECURITY_PERMISSIONS.bypassGitExfil),
106
+ permittedBypass: canBypass(GUARD_GIT_EXFIL_SEVERITY, SECURITY_PERMISSIONS.bypassGitExfil),
70
107
  })
71
108
 
72
109
  const checks: (SecurityBlock | undefined)[] = [
73
- can(SECURITY_PERMISSIONS.bypassGitRemoteTainted)
110
+ canBypass(GUARD_GIT_REMOTE_TAINTED_SEVERITY, SECURITY_PERMISSIONS.bypassGitRemoteTainted)
74
111
  ? undefined
75
112
  : withPermissionHint(
76
113
  checkGitRemoteTaintedGuard({ tool: event.tool, args: event.args, sessionId: event.sessionId }),
77
114
  SECURITY_PERMISSIONS.bypassGitRemoteTainted,
115
+ GUARD_GIT_REMOTE_TAINTED_SEVERITY,
78
116
  ),
79
- can(SECURITY_PERMISSIONS.bypassSecretExfilBash)
117
+ canBypass(GUARD_SECRET_EXFIL_BASH_SEVERITY, SECURITY_PERMISSIONS.bypassSecretExfilBash)
80
118
  ? undefined
81
119
  : withPermissionHint(
82
120
  checkSecretExfilBashGuard({ tool: event.tool, args: event.args }),
83
121
  SECURITY_PERMISSIONS.bypassSecretExfilBash,
122
+ GUARD_SECRET_EXFIL_BASH_SEVERITY,
84
123
  ),
85
- can(SECURITY_PERMISSIONS.bypassGitExfil)
124
+ canBypass(GUARD_GIT_EXFIL_SEVERITY, SECURITY_PERMISSIONS.bypassGitExfil)
86
125
  ? undefined
87
126
  : withPermissionHint(
88
127
  checkGitExfilGuard({ tool: event.tool, args: event.args, sessionId: event.sessionId }),
89
128
  SECURITY_PERMISSIONS.bypassGitExfil,
129
+ GUARD_GIT_EXFIL_SEVERITY,
90
130
  ),
91
- can(SECURITY_PERMISSIONS.bypassSecretExfilRead)
131
+ canBypass(GUARD_SECRET_EXFIL_READ_SEVERITY, SECURITY_PERMISSIONS.bypassSecretExfilRead)
92
132
  ? undefined
93
133
  : withPermissionHint(
94
134
  checkSecretExfilReadGuard({ tool: event.tool, args: event.args }),
95
135
  SECURITY_PERMISSIONS.bypassSecretExfilRead,
136
+ GUARD_SECRET_EXFIL_READ_SEVERITY,
96
137
  ),
97
- can(SECURITY_PERMISSIONS.bypassSsrf)
138
+ canBypass(GUARD_SSRF_SEVERITY, SECURITY_PERMISSIONS.bypassSsrf)
98
139
  ? undefined
99
140
  : withPermissionHint(
100
141
  checkSsrfGuard({ tool: event.tool, args: event.args }),
101
142
  SECURITY_PERMISSIONS.bypassSsrf,
143
+ GUARD_SSRF_SEVERITY,
102
144
  ),
103
- can(SECURITY_PERMISSIONS.bypassSessionSearchSecrets)
145
+ canBypass(GUARD_SESSION_SEARCH_SECRETS_SEVERITY, SECURITY_PERMISSIONS.bypassSessionSearchSecrets)
104
146
  ? undefined
105
147
  : withPermissionHint(
106
148
  checkSessionSearchSecretsGuard({ tool: event.tool, args: event.args }),
107
149
  SECURITY_PERMISSIONS.bypassSessionSearchSecrets,
150
+ GUARD_SESSION_SEARCH_SECRETS_SEVERITY,
108
151
  ),
109
- can(SECURITY_PERMISSIONS.bypassSystemPromptLeak)
152
+ canBypass(GUARD_SYSTEM_PROMPT_LEAK_SEVERITY, SECURITY_PERMISSIONS.bypassSystemPromptLeak)
110
153
  ? undefined
111
154
  : withPermissionHint(
112
155
  checkSystemPromptLeakGuard({ tool: event.tool, args: event.args }),
113
156
  SECURITY_PERMISSIONS.bypassSystemPromptLeak,
157
+ GUARD_SYSTEM_PROMPT_LEAK_SEVERITY,
114
158
  ),
115
- can(SECURITY_PERMISSIONS.bypassOutboundSecret)
159
+ canBypass(GUARD_OUTBOUND_SECRET_SEVERITY, SECURITY_PERMISSIONS.bypassOutboundSecret)
116
160
  ? undefined
117
161
  : withPermissionHint(
118
162
  checkOutboundSecretGuard({ tool: event.tool, args: event.args }),
119
163
  SECURITY_PERMISSIONS.bypassOutboundSecret,
164
+ GUARD_OUTBOUND_SECRET_SEVERITY,
120
165
  ),
121
166
  ]
122
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
+ ]
@@ -1,8 +1,28 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
  import { getRemoteTaint, recordRemoteTaint } from './remote-taint-state'
3
4
 
4
5
  export const GUARD_GIT_EXFIL = 'gitExfil'
6
+ // Classified `high` (audience-leak axis): `git push` sends every tracked
7
+ // file to a remote git host. The host (GitHub/GitLab/attacker-controlled
8
+ // box) is a third-party audience outside the operator's control loop.
9
+ // Even a private remote owned by an attacker is now outside the
10
+ // perimeter. No role auto-bypasses high — owner pushing from TUI must ack
11
+ // each push. The historical per-guard string `security.bypass.gitExfil`
12
+ // remains valid as an explicit grant for operators who knowingly want to
13
+ // re-open the auto-bypass (see SKILL.md must-not-do guidance).
14
+ export const GUARD_GIT_EXFIL_SEVERITY: SecuritySeverity = 'high'
5
15
  export const GUARD_GIT_REMOTE_TAINTED = 'gitRemoteTainted'
16
+ // Classified `high` (audience-leak axis): same path as gitExfil, second
17
+ // step. A push after a mid-session `git remote set-url` to an
18
+ // attacker-controlled URL is exactly the breach pattern that motivated
19
+ // the entire security plugin per PR #134. The recorder-vs-checker split
20
+ // (see comment on recordGitRemoteTaintIfAny below) is still load-bearing:
21
+ // the recorder fires for anyone who can run the underlying command (ack
22
+ // or the per-guard `bypassGitExfil` grant), so even if an operator
23
+ // explicitly grants `bypassGitExfil` to a role, the second-step taint
24
+ // check still fires on the eventual push.
25
+ export const GUARD_GIT_REMOTE_TAINTED_SEVERITY: SecuritySeverity = 'high'
6
26
 
7
27
  // Anchors we reuse: a `git` token must be at start-of-line or follow a shell
8
28
  // separator. This blocks `git push` while letting `cgit-something` through
@@ -1,6 +1,18 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_OUTBOUND_SECRET = 'outboundSecret'
5
+ // Classified `high` (audience-leak axis): bypass posts credential-shaped
6
+ // text to a chat channel whose readership is a third-party audience
7
+ // outside the operator's control loop. Channel readers, push-notification
8
+ // previews, search indexes, and other bots in the channel all see the
9
+ // secret before the operator can intervene. Owner-in-public-channel is
10
+ // the canonical motivating case: even owner asking the agent to "post the
11
+ // deploy status" should not be able to silently include a stack-trace
12
+ // `Bearer ghp_...` line. The whole point of the high tier is that
13
+ // audience-leak guards require per-call ack from every role, including
14
+ // owner — see AGENTS.md `## Permissions` rules of thumb.
15
+ export const GUARD_OUTBOUND_SECRET_SEVERITY: SecuritySeverity = 'high'
4
16
 
5
17
  const SIGNATURE_PATTERNS: ReadonlyArray<{ kind: string; pattern: RegExp }> = [
6
18
  { kind: 'aws_access_key_id', pattern: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|ACCA)[A-Z0-9]{16}\b/ },
@@ -463,10 +463,30 @@ export function detectPromptInjection(prompt: string): InjectionMatch[] {
463
463
 
464
464
  const DEFENSE_MARKER = '[security/prompt-injection]'
465
465
 
466
+ // Subagent prompts are constructed by trusted bundled code, not from raw
467
+ // user input. The backup-diagnose subagent in particular embeds raw git
468
+ // stderr (which legitimately contains literal "git push --help" hint
469
+ // strings on fast-forward rejection or missing-upstream failures) — those
470
+ // hits would otherwise trigger the git_exfil category and inject a "do
471
+ // NOT run git push" rule that contradicts the subagent's own
472
+ // system-prompt instructions to retry with an ack. Under the audience-
473
+ // leak policy the runtime tool.before is the universal backstop for
474
+ // `git push` regardless of role (no role auto-bypasses), so the prompt-
475
+ // side git_exfil category is strictly redundant for subagent origins.
476
+ // Other categories (system_prompt_dump, secret_demand,
477
+ // fake_privileged_skill) still fire for subagents because their threats
478
+ // (e.g. memory-logger ingesting an attacker's transcript) are real.
479
+ function filterForOrigin(matches: InjectionMatch[], origin: SessionPromptEvent['origin']): InjectionMatch[] {
480
+ if (origin?.kind !== 'subagent') return matches
481
+ return matches.filter((m) => m.category !== 'git_exfil')
482
+ }
483
+
466
484
  export function applyPromptInjectionDefense(event: SessionPromptEvent): InjectionMatch[] {
467
- const matches = detectPromptInjection(event.prompt)
468
- if (matches.length === 0) return matches
469
- if (event.prompt.includes(DEFENSE_MARKER)) return matches
485
+ const allMatches = detectPromptInjection(event.prompt)
486
+ if (allMatches.length === 0) return allMatches
487
+ if (event.prompt.includes(DEFENSE_MARKER)) return allMatches
488
+ const matches = filterForOrigin(allMatches, event.origin)
489
+ if (matches.length === 0) return allMatches
470
490
 
471
491
  const categories = Array.from(new Set(matches.map((m) => m.category))).join(', ')
472
492
  const note = [
@@ -1,6 +1,13 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SECRET_EXFIL_BASH = 'secretExfilBash'
5
+ // Classified `medium` (silent-attack axis): bypass dumps the whole
6
+ // environment (every API key, every token) into the agent's tool-result
7
+ // buffer. No direct channel side effect — operator only sees on session
8
+ // review — but the secrets are now in model context and one channel_send
9
+ // away from a third-party audience. Silent at the moment of leak.
10
+ export const GUARD_SECRET_EXFIL_BASH_SEVERITY: SecuritySeverity = 'medium'
4
11
 
5
12
  const DANGEROUS_COMMAND_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
6
13
  { pattern: /(^|[\s;|&(`$])(env|printenv)([\s;|&)`]|$)/, label: 'env / printenv (full environment dump)' },
@@ -1,8 +1,14 @@
1
1
  import path from 'node:path'
2
2
 
3
+ import type { SecuritySeverity } from '../permissions'
3
4
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
4
5
 
5
6
  export const GUARD_SECRET_EXFIL_READ = 'secretExfilRead'
7
+ // Classified `medium` (silent-attack axis): bypass returns `.env` /
8
+ // credential-file contents into model context. Same shape as
9
+ // secretExfilBash — silent at the moment of read, becomes catastrophic on
10
+ // the next channel-side tool call that quotes it.
11
+ export const GUARD_SECRET_EXFIL_READ_SEVERITY: SecuritySeverity = 'medium'
6
12
 
7
13
  const SENSITIVE_BASENAMES = new Set([
8
14
  '.env',
@@ -1,6 +1,15 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SESSION_SEARCH_SECRETS = 'sessionSearchSecrets'
5
+ // Classified `medium` (silent-attack axis): bypass returns secret-shaped
6
+ // session-search hits into the agent's tool-result buffer. The operator
7
+ // doesn't see the raw hits — the agent summarizes them — so the leak is
8
+ // silent from the operator's perspective even though it's a read tool.
9
+ // The hits then live in model context as a precondition for a later
10
+ // channel_send leak; outboundSecret would catch the actual send, but
11
+ // silent-recon-then-summarize is its own attack shape.
12
+ export const GUARD_SESSION_SEARCH_SECRETS_SEVERITY: SecuritySeverity = 'medium'
4
13
 
5
14
  const SESSION_SEARCH_TOOLS: ReadonlySet<string> = new Set([
6
15
  'session_search',
@@ -1,6 +1,12 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SSRF = 'ssrf'
5
+ // Classified `medium` (silent-attack axis): bypass lets `curl
6
+ // http://169.254.169.254/...` return cloud-metadata IAM credentials into
7
+ // model context. Silent — no channel side effect at the moment of fetch.
8
+ // Catastrophic on follow-up because the model now has live cloud creds.
9
+ export const GUARD_SSRF_SEVERITY: SecuritySeverity = 'medium'
4
10
 
5
11
  const ALWAYS_BLOCKED_HOSTS = new Set([
6
12
  'localhost',
@@ -1,6 +1,13 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SYSTEM_PROMPT_LEAK = 'systemPromptLeak'
5
+ // Classified `high` (audience-leak axis): bypass posts TypeClaw runtime
6
+ // fingerprints / system-prompt fragments to a chat. Same shape as
7
+ // outboundSecret — third-party audience, no operator intervention before
8
+ // the leak lands. Disclosure also enables recon for later targeted
9
+ // prompt-injection attacks against this agent.
10
+ export const GUARD_SYSTEM_PROMPT_LEAK_SEVERITY: SecuritySeverity = 'high'
4
11
 
5
12
  const FINGERPRINT_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [
6
13
  { label: 'TypeClaw runtime preamble', pattern: /You are a general-purpose AI agent running inside TypeClaw\./ },