typeclaw 0.15.2 → 0.17.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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +35 -2
  3. package/src/agent/plugin-tools.ts +38 -0
  4. package/src/agent/session-meta.ts +6 -2
  5. package/src/agent/session-origin.ts +111 -14
  6. package/src/agent/subagents.ts +6 -1
  7. package/src/agent/system-prompt.ts +41 -32
  8. package/src/agent/tools/channel-reply.ts +3 -2
  9. package/src/agent/tools/grant-role.ts +214 -0
  10. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -6
  11. package/src/bundled-plugins/memory/index.ts +25 -6
  12. package/src/bundled-plugins/security/index.ts +12 -0
  13. package/src/bundled-plugins/security/policies/private-surface-read.ts +215 -0
  14. package/src/channels/adapters/github/inbound.ts +54 -1
  15. package/src/channels/adapters/github/index.ts +1 -0
  16. package/src/channels/router.ts +150 -37
  17. package/src/cli/inspect.ts +20 -9
  18. package/src/cli/role.ts +10 -1
  19. package/src/cli/ui.ts +6 -4
  20. package/src/config/reloadable.ts +10 -3
  21. package/src/init/index.ts +24 -42
  22. package/src/init/paths.ts +1 -0
  23. package/src/init/run-owner-claim.ts +21 -3
  24. package/src/inspect/label.ts +2 -0
  25. package/src/inspect/live.ts +6 -1
  26. package/src/inspect/render.ts +8 -2
  27. package/src/inspect/replay.ts +6 -1
  28. package/src/inspect/types.ts +4 -1
  29. package/src/permissions/builtins.ts +22 -0
  30. package/src/permissions/grant.ts +92 -16
  31. package/src/permissions/index.ts +8 -2
  32. package/src/permissions/permissions.ts +16 -0
  33. package/src/permissions/resolve.ts +10 -0
  34. package/src/plugin/types.ts +12 -0
  35. package/src/role-claim/index.ts +1 -0
  36. package/src/role-claim/reload-after-claim.ts +34 -0
  37. package/src/run/channel-session-factory.ts +6 -1
  38. package/src/run/index.ts +18 -1
  39. package/src/sandbox/build.ts +51 -1
  40. package/src/sandbox/hidden-paths.ts +41 -0
  41. package/src/sandbox/index.ts +2 -1
  42. package/src/sandbox/policy.ts +15 -0
  43. package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
  44. package/src/skills/typeclaw-permissions/SKILL.md +11 -3
  45. package/src/skills/typeclaw-skills/SKILL.md +3 -1
  46. package/src/skills/typeclaw-troubleshooting/SKILL.md +104 -0
  47. package/src/usage/report.ts +4 -0
  48. package/src/usage/scan.ts +1 -1
@@ -0,0 +1,214 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import {
5
+ grantRole,
6
+ grantRolePermission,
7
+ isDmChannelOrigin,
8
+ parseMatchRule,
9
+ type PermissionService,
10
+ type RolesConfig,
11
+ } from '@/permissions'
12
+
13
+ import type { SessionOrigin } from '../session-origin'
14
+
15
+ export type GrantRoleToolDetails =
16
+ | { ok: true; mode: 'match' | 'permission'; role: string; value: string; added: boolean; restartRequired: boolean }
17
+ | { ok: false; error: string }
18
+
19
+ export type CreateGrantRoleToolOptions = {
20
+ agentDir: string
21
+ getOrigin: () => SessionOrigin | undefined
22
+ permissions: PermissionService
23
+ // Re-reads roles FROM DISK and returns the fresh set, for hot-reloading a
24
+ // match-grant after grantRole writes typeclaw.json. Must NOT read an
25
+ // in-memory config snapshot: grantRole writes the file directly, so a
26
+ // snapshot taken before the live config pointer is reloaded would be stale
27
+ // and replaceRoles would reapply the pre-grant table. Production wires this
28
+ // to reloadConfig(agentDir) + getConfig().roles, matching the config
29
+ // reloadable. A permission-grant is restart-required, so the reload has no
30
+ // runtime effect for it, but we reload anyway to keep a same-session
31
+ // subsequent match-grant reading a consistent table.
32
+ reloadRoles: () => RolesConfig | undefined
33
+ }
34
+
35
+ // Roles this tool may target, lowest to highest. `guest` is a valid target
36
+ // (an operator opening guest channel.respond) but never a granter — the gate
37
+ // below requires the caller to resolve to owner/trusted.
38
+ const TIER_ORDER = ['guest', 'member', 'trusted', 'owner'] as const
39
+ type TierRole = (typeof TIER_ORDER)[number]
40
+
41
+ function tierOf(role: string): number {
42
+ return TIER_ORDER.indexOf(role as TierRole)
43
+ }
44
+
45
+ function isTierRole(role: string): role is TierRole {
46
+ return (TIER_ORDER as readonly string[]).includes(role)
47
+ }
48
+
49
+ // A single-principal turn carries only the principal's own first-party words:
50
+ // the TUI (a human typing directly) or a 1:1 DM (principal + bot, no
51
+ // third-party messages buffered in). A group/open channel turn always mixes in
52
+ // other authors' messages, which is the confused-deputy surface that lets a
53
+ // guest prompt-inject a trusted turn into rewriting the access-control table.
54
+ // Role grants are confined to single-principal turns so that surface does not
55
+ // exist.
56
+ function isSinglePrincipalOrigin(origin: SessionOrigin | undefined): boolean {
57
+ if (origin === undefined) return false
58
+ if (origin.kind === 'tui') return true
59
+ if (origin.kind === 'channel') return isDmChannelOrigin(origin)
60
+ return false
61
+ }
62
+
63
+ export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
64
+ const { agentDir, getOrigin, permissions, reloadRoles } = options
65
+
66
+ return defineTool({
67
+ name: 'grant_role',
68
+ label: 'Grant Role',
69
+ description:
70
+ 'Assign an author to a role (match grant) or give a role a capability (permission grant), by editing typeclaw.json#roles. ' +
71
+ 'Use this to onboard a teammate ("respond to author U_X" → grant them member) or to open the agent to a wider audience ' +
72
+ '("let anyone in this channel message you" → grant guest channel.respond). ' +
73
+ 'Only callable from the TUI or a 1:1 DM by an owner or trusted user — group-channel turns cannot use it. ' +
74
+ 'Permission grants are restart-required: they land in typeclaw.json but take effect on the next `typeclaw restart`.',
75
+ parameters: Type.Object({
76
+ role: Type.Union(
77
+ [Type.Literal('owner'), Type.Literal('trusted'), Type.Literal('member'), Type.Literal('guest')],
78
+ {
79
+ description: 'The role to grant TO.',
80
+ },
81
+ ),
82
+ match: Type.Optional(
83
+ Type.String({
84
+ description:
85
+ 'A match rule assigning an author/scope to the role, e.g. "slack:T0123 author:U_WIFE". ' +
86
+ 'Provide exactly one of match or permission.',
87
+ }),
88
+ ),
89
+ permission: Type.Optional(
90
+ Type.String({
91
+ description:
92
+ 'A capability to add to the role, e.g. "channel.respond". Provide exactly one of match or permission. ' +
93
+ 'security.bypass.* permissions cannot be granted through this tool.',
94
+ }),
95
+ ),
96
+ }),
97
+
98
+ async execute(_toolCallId, params): Promise<ToolReturn> {
99
+ const origin = getOrigin()
100
+
101
+ if (!isSinglePrincipalOrigin(origin)) {
102
+ return err(
103
+ 'grant_role is only available from the TUI or a 1:1 DM. A group-channel turn cannot change roles, ' +
104
+ 'because it mixes in other participants\u2019 messages (prompt-injection surface).',
105
+ )
106
+ }
107
+
108
+ const callerRole = permissions.resolveRole(origin)
109
+ if (callerRole !== 'owner' && callerRole !== 'trusted') {
110
+ return err(`grant_role denied: caller resolves to '${callerRole}'; only owner or trusted may grant roles.`)
111
+ }
112
+
113
+ const hasMatch = typeof params.match === 'string' && params.match.length > 0
114
+ const hasPermission = typeof params.permission === 'string' && params.permission.length > 0
115
+ if (hasMatch === hasPermission) {
116
+ return err('Provide exactly one of `match` or `permission`.')
117
+ }
118
+
119
+ if (!isTierRole(params.role)) {
120
+ return err(`Unknown target role '${params.role}'.`)
121
+ }
122
+
123
+ // Tier ceiling: a granter may not assign or empower a role ABOVE its own.
124
+ // owner(3) ≥ trusted(2) ≥ member(1) ≥ guest(0).
125
+ if (tierOf(params.role) > tierOf(callerRole)) {
126
+ return err(`grant_role denied: a ${callerRole} caller cannot grant the higher '${params.role}' role.`)
127
+ }
128
+
129
+ return hasMatch
130
+ ? grantMatch(params.role, params.match as string)
131
+ : grantPermission(callerRole, params.role, params.permission as string)
132
+ },
133
+ })
134
+
135
+ function grantMatch(role: string, matchRule: string): ToolReturn {
136
+ const parsed = parseMatchRule(matchRule)
137
+ if (!parsed.ok) {
138
+ return err(`Invalid match rule '${matchRule}': ${parsed.error}`)
139
+ }
140
+
141
+ const result = grantRole({ cwd: agentDir, roleName: role, matchRule })
142
+ if (!result.ok) return err(result.reason)
143
+
144
+ reload()
145
+ return ok('match', role, matchRule, result.added, false)
146
+ }
147
+
148
+ function grantPermission(callerRole: string, role: string, permission: string): ToolReturn {
149
+ // security.bypass.* defeats guards rather than enabling a feature; never
150
+ // grantable through an agent tool. It stays a deliberate hand-edit gated by
151
+ // the rolePromotion guard + an explicit ack.
152
+ if (permission.startsWith('security.bypass.')) {
153
+ return err(
154
+ `grant_role refuses to grant '${permission}': security.bypass.* permissions disable security guards and ` +
155
+ 'must be set by hand-editing typeclaw.json (gated by the rolePromotion guard), not via this tool.',
156
+ )
157
+ }
158
+
159
+ // "Grant only what you hold": the caller cannot confer a capability it does
160
+ // not itself possess. Owner's resolved set is the expanded wildcard, so an
161
+ // owner can grant any non-bypass core permission; trusted is capped at its
162
+ // literal set.
163
+ const callerPerms = permissions.describe(getOrigin()).permissions
164
+ if (!callerPerms.includes(permission)) {
165
+ return err(
166
+ `grant_role denied: a ${callerRole} caller cannot grant '${permission}' because it does not hold that permission.`,
167
+ )
168
+ }
169
+
170
+ const result = grantRolePermission({ cwd: agentDir, roleName: role, permission })
171
+ if (!result.ok) return err(result.reason)
172
+
173
+ reload()
174
+ return ok('permission', role, permission, result.added, true)
175
+ }
176
+
177
+ function reload(): void {
178
+ try {
179
+ permissions.replaceRoles(reloadRoles())
180
+ } catch {
181
+ // Best-effort hot-reload of match-grants. A failure here does not undo
182
+ // the on-disk write; the next config reload / restart picks it up.
183
+ }
184
+ }
185
+ }
186
+
187
+ function ok(
188
+ mode: 'match' | 'permission',
189
+ role: string,
190
+ value: string,
191
+ added: boolean,
192
+ restartRequired: boolean,
193
+ ): ToolReturn {
194
+ const details: GrantRoleToolDetails = { ok: true, mode, role, value, added, restartRequired }
195
+ const note = added ? '' : ' (already on file)'
196
+ const restart = restartRequired
197
+ ? ' This permission grant is restart-required; run `typeclaw restart` for it to take effect.'
198
+ : ''
199
+ const text =
200
+ mode === 'match'
201
+ ? `Granted role '${role}' the match rule '${value}'${note}.${restart}`
202
+ : `Granted role '${role}' the permission '${value}'${note}.${restart}`
203
+ return { content: [{ type: 'text', text }], details }
204
+ }
205
+
206
+ function err(message: string): ToolReturn {
207
+ const details: GrantRoleToolDetails = { ok: false, error: message }
208
+ return { content: [{ type: 'text', text: message }], details }
209
+ }
210
+
211
+ type ToolReturn = {
212
+ content: { type: 'text'; text: string }[]
213
+ details: GrantRoleToolDetails
214
+ }
@@ -20,12 +20,14 @@ const AGENT_ROOT_WRITE_ALLOWLIST = new Set([
20
20
  'typeclaw.json',
21
21
  ])
22
22
 
23
- // `packages/` is a bun workspace root scaffolded at init (see
24
- // src/init/index.ts#DIRECTORIES). Reusable systems and custom typeclaw
25
- // plugins live there as standalone packages, so the agent must be able to
26
- // write into `packages/<name>/...` without acknowledging the guard — same
27
- // as `workspace/`, but for code intended to be reused rather than discarded.
28
- const AGENT_ROOT_DIRECTORY_ALLOWLIST = new Set(['mounts', 'packages'])
23
+ // All scaffolded write zones outside `workspace/` (see
24
+ // src/init/index.ts#DIRECTORIES) that the agent may write into without
25
+ // acknowledging the guard. `packages/` holds reusable systems and custom
26
+ // typeclaw plugins as standalone packages; `public/` is the guest-visible
27
+ // zone for anything intended to be shared out. Both are deliberate write
28
+ // targets, same as `workspace/`, so an unacknowledged write is expected, not
29
+ // suspicious.
30
+ const AGENT_ROOT_DIRECTORY_ALLOWLIST = new Set(['mounts', 'packages', 'public'])
29
31
 
30
32
  export async function checkNonWorkspaceWriteGuard(options: {
31
33
  tool: string
@@ -6,7 +6,7 @@ import { CronExpressionParser } from 'cron-parser'
6
6
  import { z } from 'zod'
7
7
 
8
8
  import type { SessionOrigin } from '@/agent/session-origin'
9
- import { definePlugin } from '@/plugin'
9
+ import { definePlugin, type SpawnSubagentOptions } from '@/plugin'
10
10
  import { formatLocalDate } from '@/shared'
11
11
 
12
12
  import { createDreamingSubagent, type DreamingPayload } from './dreaming'
@@ -205,9 +205,20 @@ export default definePlugin({
205
205
  ...(last.origin !== undefined ? { origin: last.origin } : {}),
206
206
  ...(streamLineCursor !== undefined ? { streamLineCursor } : {}),
207
207
  }
208
- const spawnOptions = {
208
+ // Execution authority is `system` (resolves to owner), NOT the
209
+ // triggering turn's role: memory-logging is TypeClaw infrastructure over
210
+ // operator-owned sessions//memory/, so a guest channel turn that triggers
211
+ // it must not demote the logger to guest and get its transcript read
212
+ // blocked by privateSurfaceRead. The triggering origin is preserved two
213
+ // ways: `triggeredBy` for audit provenance, and `payload.origin` for
214
+ // content provenance (memory extraction/retrieval channel-safety).
215
+ const spawnOptions: SpawnSubagentOptions = {
209
216
  parentSessionId: sessionId,
210
- ...(last.origin !== undefined ? { spawnedByOrigin: last.origin } : {}),
217
+ spawnedByOrigin: {
218
+ kind: 'system',
219
+ component: 'memory-logger',
220
+ ...(last.origin !== undefined ? { triggeredBy: last.origin } : {}),
221
+ },
211
222
  }
212
223
  const next = spawnChain
213
224
  .catch(() => undefined)
@@ -280,10 +291,18 @@ export default definePlugin({
280
291
  cacheFilePath,
281
292
  ...(event.origin !== undefined ? { origin: event.origin } : {}),
282
293
  }
283
- await ctx.spawnSubagent('memory-retrieval', payload, {
294
+ // System authority, not the triggering turn's role — see the
295
+ // memory-logger spawn above. memory-retrieval writes
296
+ // memory/.retrieval-cache/, which a guest-demoted role cannot.
297
+ const retrievalSpawnOptions: SpawnSubagentOptions = {
284
298
  parentSessionId: event.sessionId,
285
- ...(event.origin !== undefined ? { spawnedByOrigin: event.origin } : {}),
286
- })
299
+ spawnedByOrigin: {
300
+ kind: 'system',
301
+ component: 'memory-retrieval',
302
+ ...(event.origin !== undefined ? { triggeredBy: event.origin } : {}),
303
+ },
304
+ }
305
+ await ctx.spawnSubagent('memory-retrieval', payload, retrievalSpawnOptions)
287
306
  }
288
307
 
289
308
  // Subagents are constructed at boot here (rather than imported as constants)
@@ -1,4 +1,5 @@
1
1
  import { definePlugin } from '@/plugin'
2
+ import { resolveHiddenPaths } from '@/sandbox'
2
3
 
3
4
  import { HIGH_TIER_PER_GUARD_PERMISSIONS, SECURITY_PERMISSIONS, SEVERITY_PERMISSION } from './permissions'
4
5
  import type { SecurityPermission, SecuritySeverity } from './permissions'
@@ -11,6 +12,7 @@ import {
11
12
  recordGitRemoteTaintIfAny,
12
13
  } from './policies/git-exfil'
13
14
  import { GUARD_OUTBOUND_SECRET_SEVERITY, checkOutboundSecretGuard } from './policies/outbound-secret-scan'
15
+ import { checkPrivateSurfaceReadGuard } from './policies/private-surface-read'
14
16
  import { applyPromptInjectionDefense } from './policies/prompt-injection'
15
17
  import { clearSessionTaints } from './policies/remote-taint-state'
16
18
  import { GUARD_ROLE_PROMOTION_SEVERITY, checkRolePromotionGuard } from './policies/role-promotion'
@@ -161,6 +163,16 @@ export default definePlugin({
161
163
  SECURITY_PERMISSIONS.bypassSecretExfilRead,
162
164
  GUARD_SECRET_EXFIL_READ_SEVERITY,
163
165
  ),
166
+ // Role-derived, not severity-bypassed: resolveHiddenPaths already
167
+ // returns an empty deny-list for roles that may see the surface, so
168
+ // there is no canBypass wrapper. Mirrors the bash sandbox masks onto
169
+ // the non-bash read/grep/find/ls/edit/write builtins.
170
+ checkPrivateSurfaceReadGuard({
171
+ tool: event.tool,
172
+ args: event.args,
173
+ agentDir: ctx.agentDir,
174
+ hidden: resolveHiddenPaths(ctx.permissions, event.origin, ctx.agentDir),
175
+ }),
164
176
  canBypass(GUARD_SSRF_SEVERITY, SECURITY_PERMISSIONS.bypassSsrf)
165
177
  ? undefined
166
178
  : withPermissionHint(
@@ -0,0 +1,215 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import type { HiddenPaths } from '@/sandbox'
5
+
6
+ import type { SecurityBlock } from '../policy'
7
+
8
+ export const GUARD_PRIVATE_SURFACE_READ = 'privateSurfaceRead'
9
+
10
+ // bash is excluded: its access to hidden paths is contained by the bwrap
11
+ // sandbox (applyBashSandbox), not by blocking the call. Every OTHER tool is
12
+ // scanned, so a new file-reading tool — bundled or third-party — is covered
13
+ // the day it ships without a whitelist edit. websearch/webfetch take URLs, not
14
+ // local paths, and the path-plausibility filter keeps their args from matching.
15
+ const UNSCANNED_TOOLS = new Set(['bash'])
16
+
17
+ // The bash sandbox hides the role's private surface — the working DIRECTORIES
18
+ // (workspace/, memory/, sessions/) and the secret FILES (.env, secrets.json) —
19
+ // via bwrap masks, but every non-bash tool runs in the main process, outside
20
+ // any sandbox. find_entry, look_at, and the channel attachment tools all read
21
+ // files by a caller-supplied path, so without a guard a restricted role could
22
+ // read back through them exactly what bash masking denies. This guard mirrors
23
+ // the WHOLE deny-list (dirs + files) onto all of them, honouring the PR's
24
+ // "two enforcement points, one deny-list" invariant.
25
+ //
26
+ // It covers the full deny-list rather than delegating secret files to the
27
+ // secretExfilRead guard: that guard only inspects read/grep/find/ls (not
28
+ // edit/write/look_at/channel_send) and is acknowledgement-bypassable, so
29
+ // delegating would leave .env/secrets.json reachable through the uncovered
30
+ // tools — exactly the gap the bash masks close. secretExfilRead remains as
31
+ // independent defense in depth for the four tools it does cover.
32
+ //
33
+ // Posture is FAIL-CLOSED for restricted roles: it does not whitelist a known
34
+ // set of tools (that fails open the moment a new reader is added). It scans
35
+ // every arg of every non-bash tool — recursively, since paths hide in nested
36
+ // shapes like look_at's images[].path and channel_send's attachments[].path —
37
+ // and blocks any string that resolves to (a secret file) or under (a hidden
38
+ // directory) the deny-list.
39
+ export function checkPrivateSurfaceReadGuard(options: {
40
+ tool: string
41
+ args: Record<string, unknown>
42
+ agentDir: string
43
+ hidden: HiddenPaths
44
+ }): SecurityBlock | undefined {
45
+ const { tool, args, agentDir, hidden } = options
46
+ if (UNSCANNED_TOOLS.has(tool)) return undefined
47
+ const deniedDirs = hidden.dirs
48
+ const deniedFiles = hidden.files
49
+ if (deniedDirs.length === 0 && deniedFiles.length === 0) return undefined
50
+
51
+ for (const candidate of collectPathCandidates(args, tool)) {
52
+ const hit = matchHidden(candidate, agentDir, deniedDirs, deniedFiles)
53
+ if (hit !== undefined) {
54
+ return {
55
+ block: true,
56
+ reason: [
57
+ `Guard \`${GUARD_PRIVATE_SURFACE_READ}\` blocked ${tool}: argument \`${candidate}\` resolves to ${hit}, which is hidden from the current role.`,
58
+ 'The bash sandbox masks the same path; reaching it through another tool is the same disclosure.',
59
+ ].join(' '),
60
+ }
61
+ }
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ // Field names whose values are ALWAYS free text (prose/queries/ids), NEVER a
67
+ // filesystem path, for EVERY tool. Scanning them caused false positives: a
68
+ // guest's `channel_reply({ text: "the memory leak" })` or `websearch({ query:
69
+ // "workspace setup" })` resolve to a bare hidden-dir name and were wrongly
70
+ // blocked. This is a DENYLIST OF KEY NAMES, not a tool whitelist: an unknown
71
+ // field on an unknown tool is still scanned (fail-closed for new path-bearing
72
+ // readers); we only skip values whose KEY is universally free text. `command`
73
+ // is here because bash (its only user) is already exempt via UNSCANNED_TOOLS.
74
+ //
75
+ // `glob` and `pattern` are deliberately ABSENT — they are tool-dependent (a
76
+ // glob/path-filter in grep/find, a regex only in grep) and handled by
77
+ // FREE_TEXT_KEYS_BY_TOOL below.
78
+ const NON_PATH_KEYS = new Set([
79
+ 'text',
80
+ 'query',
81
+ 'prompt',
82
+ 'selector',
83
+ 'url',
84
+ 'message',
85
+ 'body',
86
+ 'content',
87
+ 'command',
88
+ 'reason',
89
+ 'subject',
90
+ 'description',
91
+ 'title',
92
+ 'name',
93
+ // edit tool: replacement text is free-form and may quote a hidden path.
94
+ 'oldText',
95
+ 'newText',
96
+ // memory append tool: fragment topic is free text.
97
+ 'topic',
98
+ // channel_send/channel_reply attachments[].filename and
99
+ // channel_fetch_attachment.filename: display-only metadata (defaults to the
100
+ // basename of the real `path`), never the file location the guard cares
101
+ // about — `attachments[].path` carries that and is NOT exempted.
102
+ 'filename',
103
+ ])
104
+
105
+ // Keys that are free text in SPECIFIC tools but path-bearing in others, so a
106
+ // global denylist would either over-block or open a bypass. Scoped per tool:
107
+ // - grep.pattern : a regex/search string (e.g. "sessions"), NOT a path.
108
+ // Notably NOT listed (and therefore SCANNED):
109
+ // - grep.glob / find.pattern : both are glob path-filters resolved RELATIVE
110
+ // to the search root, so `grep({ path: '.', glob: 'workspace/**' })` and
111
+ // `find({ path: '.', pattern: 'workspace/**' })` reach a hidden subtree.
112
+ // Exempting them let the only hidden-identifying arg through (the bypass a
113
+ // review caught). They have no false-positive risk: path.resolve treats
114
+ // glob metacharacters as literal, so `*.ts` -> `/agent/*.ts` (passes) while
115
+ // `workspace/**` -> `/agent/workspace/**` (correctly blocked).
116
+ // Fail-closed: only the listed tool's listed key is exempted; an unknown tool
117
+ // (or grep gaining a new key) scans everything.
118
+ const FREE_TEXT_KEYS_BY_TOOL: Record<string, ReadonlySet<string>> = {
119
+ grep: new Set(['pattern']),
120
+ }
121
+
122
+ // Recursively collects strings that could be paths, skipping values under a
123
+ // universally-free-text key or a tool-scoped free-text key. matchHidden then
124
+ // realpath-resolves each candidate and fires only on one landing inside a
125
+ // hidden directory. Fail-closed by design: a bare path-bearing value equal to a
126
+ // hidden dir name (e.g. `path: "memory"`) is still blocked. `underExempt`
127
+ // propagates so nested values under an exempt key (e.g. a structured pattern)
128
+ // stay exempt; top-level strings and array elements carry no key and are always
129
+ // scanned (so attachments[].path is collected).
130
+ function collectPathCandidates(value: unknown, tool: string): string[] {
131
+ const out: string[] = []
132
+ walk(value, out, tool, false)
133
+ return out
134
+ }
135
+
136
+ function walk(value: unknown, out: string[], tool: string, underExempt: boolean): void {
137
+ if (typeof value === 'string') {
138
+ if (underExempt) return
139
+ out.push(value)
140
+ return
141
+ }
142
+ if (Array.isArray(value)) {
143
+ for (const item of value) walk(item, out, tool, underExempt)
144
+ return
145
+ }
146
+ if (value !== null && typeof value === 'object') {
147
+ const toolFreeText = FREE_TEXT_KEYS_BY_TOOL[tool]
148
+ for (const [key, item] of Object.entries(value)) {
149
+ const keyIsExempt = NON_PATH_KEYS.has(key) || (toolFreeText?.has(key) ?? false)
150
+ walk(item, out, tool, underExempt || keyIsExempt)
151
+ }
152
+ }
153
+ }
154
+
155
+ // Resolving both sides against agentDir defeats traversal (workspace/../workspace/x),
156
+ // relative forms (./workspace), and absolute restatements. Secret files match on
157
+ // exact equality; hidden directories match the dir itself or anything under it,
158
+ // using a trailing slash so `workspace` does not also match a sibling
159
+ // `workspace-notes`.
160
+ //
161
+ // Symlink defense: lexical path.resolve is NOT enough. A restricted role can
162
+ // plant `public/leak -> ../.env` (or `-> ../memory`) via sandboxed bash, then
163
+ // read it back through a non-bash tool whose path lexically lands in the
164
+ // guest-visible `public/`. So we resolve the candidate's REAL path
165
+ // (realpathRealIntendedPath follows symlinks on every existing path component)
166
+ // before matching. Both sides are realpath'd because agentDir itself may sit
167
+ // under a symlink (e.g. /tmp -> /private/tmp on macOS); comparing a real
168
+ // candidate against a lexical deny-list would never match.
169
+ function matchHidden(
170
+ candidate: string,
171
+ agentDir: string,
172
+ deniedDirs: string[],
173
+ deniedFiles: string[],
174
+ ): string | undefined {
175
+ const resolved = realpathRealIntendedPath(path.resolve(agentDir, candidate))
176
+ for (const file of deniedFiles) {
177
+ if (resolved === realpathRealIntendedPath(file)) return file
178
+ }
179
+ for (const dir of deniedDirs) {
180
+ const realDir = realpathRealIntendedPath(dir)
181
+ if (resolved === realDir || resolved.startsWith(`${realDir}/`)) return dir
182
+ }
183
+ return undefined
184
+ }
185
+
186
+ // Resolves symlinks on the longest existing prefix of an absolute path, then
187
+ // re-appends the non-existent tail. A bare realpathSync throws on a path that
188
+ // does not exist yet (a write target, or a read of a not-yet-created file), so
189
+ // we walk up to the nearest existing ancestor, realpath THAT (collapsing any
190
+ // symlinked component including a planted symlink), and rejoin the remainder.
191
+ // This catches `public/leak/x` where `public/leak` is a symlink into a hidden
192
+ // dir even though `public/leak/x` itself does not exist. Sync (realpathSync)
193
+ // keeps the guard synchronous so the security tool.before check array stays
194
+ // non-async; the cost is one syscall per existing component, negligible at the
195
+ // tool-call boundary. Sync mirror of resolveRealIntendedPath in the guard
196
+ // plugin's non-workspace-write policy.
197
+ function realpathRealIntendedPath(absolutePath: string): string {
198
+ const pending: string[] = []
199
+ let current = absolutePath
200
+ while (true) {
201
+ try {
202
+ return path.join(realpathSync.native(current), ...pending.reverse())
203
+ } catch (err) {
204
+ if (!isNotFoundError(err)) throw err
205
+ }
206
+ const parent = path.dirname(current)
207
+ if (parent === current) return absolutePath
208
+ pending.push(path.basename(current))
209
+ current = parent
210
+ }
211
+ }
212
+
213
+ function isNotFoundError(err: unknown): boolean {
214
+ return err instanceof Error && 'code' in err && err.code === 'ENOENT'
215
+ }
@@ -13,6 +13,9 @@ export type GithubWebhookHandlerOptions = {
13
13
  allowlist: () => readonly string[]
14
14
  selfId: () => string | null
15
15
  selfLogin: () => string | null
16
+ // Defaults to 'pat' when omitted. Only 'app' promotes an opened PR to a
17
+ // review request; see classifyOpenedAsReview for why.
18
+ authType?: () => 'pat' | 'app'
16
19
  route: (message: InboundMessage) => void
17
20
  logger: GithubInboundLogger
18
21
  // Optional: resolves whether the bot is a member of the given team. When
@@ -56,6 +59,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
56
59
  const teamIsBotMember = await resolveTeamMembership(event, payload, options)
57
60
  const classified = classifyGithubInbound(event, payload, selfLogin, {
58
61
  teamIsBotMember,
62
+ authType: options.authType?.() ?? 'pat',
59
63
  })
60
64
  if (classified === null) return ok()
61
65
 
@@ -77,7 +81,7 @@ export function classifyGithubInbound(
77
81
  event: string,
78
82
  payload: Record<string, unknown>,
79
83
  selfLogin: string | null,
80
- options?: { teamIsBotMember?: boolean },
84
+ options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app' },
81
85
  ): InboundMessage | null {
82
86
  const repository = readRepository(payload)
83
87
  if (repository === null) return null
@@ -177,6 +181,14 @@ export function classifyGithubInbound(
177
181
  teamIsBotMember: options?.teamIsBotMember,
178
182
  })
179
183
  }
184
+ // A GitHub App cannot be added to a PR's requested_reviewers, so it never
185
+ // receives a review_requested event targeting itself. The opened event is
186
+ // the only signal it can act on, so in App mode an opened PR is promoted to
187
+ // a review request. A PAT-backed bot is a real user that can be requested,
188
+ // so it waits for the explicit request instead of reviewing every PR.
189
+ if (action === 'opened' && options?.authType === 'app') {
190
+ return classifyOpenedAsReview({ payload, pr, number, base, selfLogin })
191
+ }
180
192
  return buildInbound(
181
193
  { ...base, chat: `pr:${number}`, thread: null },
182
194
  pr.body,
@@ -291,6 +303,47 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
291
303
  }
292
304
  }
293
305
 
306
+ type OpenedAsReviewInput = {
307
+ payload: Record<string, unknown>
308
+ pr: Record<string, unknown>
309
+ number: number
310
+ base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
311
+ selfLogin: string | null
312
+ }
313
+
314
+ function classifyOpenedAsReview(input: OpenedAsReviewInput): InboundMessage | null {
315
+ const { payload, pr, number, base, selfLogin } = input
316
+ if (selfLogin === null) return null
317
+ const sender = readUser(payload.sender)
318
+ if (sender === null) return null
319
+ if (sender.login === selfLogin) return null
320
+
321
+ const title = readString(pr, 'title') ?? `#${number}`
322
+ const head = readString(readRecord(pr.head), 'ref')
323
+ const baseRef = readString(readRecord(pr.base), 'ref')
324
+ const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
325
+ const text =
326
+ `@${sender.login} requested your review on PR #${number}: "${title}".${branchSegment}` +
327
+ ' Please review the changes line-by-line and post your feedback.'
328
+
329
+ const updatedAt = readString(pr, 'updated_at') ?? ''
330
+ const prId = readNumber(pr, 'id') ?? number
331
+
332
+ return {
333
+ ...base,
334
+ chat: `pr:${number}`,
335
+ thread: null,
336
+ text,
337
+ externalMessageId: `pr-${prId}-opened-${updatedAt}`,
338
+ authorId: String(sender.id),
339
+ authorName: sender.login,
340
+ authorIsBot: sender.type === 'Bot',
341
+ isBotMention: true,
342
+ replyToBotMessageId: null,
343
+ ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
344
+ }
345
+ }
346
+
294
347
  export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
295
348
 
296
349
  export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
@@ -128,6 +128,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
128
128
  allowlist: () => options.configRef().eventAllowlist,
129
129
  selfId: () => selfId,
130
130
  selfLogin: () => selfLogin,
131
+ authType: () => options.secrets.auth.type,
131
132
  isBotInTeam,
132
133
  logger,
133
134
  route: (message) => {