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.
- package/package.json +1 -1
- package/src/agent/index.ts +35 -2
- package/src/agent/plugin-tools.ts +38 -0
- package/src/agent/session-meta.ts +6 -2
- package/src/agent/session-origin.ts +111 -14
- package/src/agent/subagents.ts +6 -1
- package/src/agent/system-prompt.ts +41 -32
- package/src/agent/tools/channel-reply.ts +3 -2
- package/src/agent/tools/grant-role.ts +214 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -6
- package/src/bundled-plugins/memory/index.ts +25 -6
- package/src/bundled-plugins/security/index.ts +12 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +215 -0
- package/src/channels/adapters/github/inbound.ts +54 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/router.ts +150 -37
- package/src/cli/inspect.ts +20 -9
- package/src/cli/role.ts +10 -1
- package/src/cli/ui.ts +6 -4
- package/src/config/reloadable.ts +10 -3
- package/src/init/index.ts +24 -42
- package/src/init/paths.ts +1 -0
- package/src/init/run-owner-claim.ts +21 -3
- package/src/inspect/label.ts +2 -0
- package/src/inspect/live.ts +6 -1
- package/src/inspect/render.ts +8 -2
- package/src/inspect/replay.ts +6 -1
- package/src/inspect/types.ts +4 -1
- package/src/permissions/builtins.ts +22 -0
- package/src/permissions/grant.ts +92 -16
- package/src/permissions/index.ts +8 -2
- package/src/permissions/permissions.ts +16 -0
- package/src/permissions/resolve.ts +10 -0
- package/src/plugin/types.ts +12 -0
- package/src/role-claim/index.ts +1 -0
- package/src/role-claim/reload-after-claim.ts +34 -0
- package/src/run/channel-session-factory.ts +6 -1
- package/src/run/index.ts +18 -1
- package/src/sandbox/build.ts +51 -1
- package/src/sandbox/hidden-paths.ts +41 -0
- package/src/sandbox/index.ts +2 -1
- package/src/sandbox/policy.ts +15 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
- package/src/skills/typeclaw-permissions/SKILL.md +11 -3
- package/src/skills/typeclaw-skills/SKILL.md +3 -1
- package/src/skills/typeclaw-troubleshooting/SKILL.md +104 -0
- package/src/usage/report.ts +4 -0
- 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
|
-
//
|
|
24
|
-
// src/init/index.ts#DIRECTORIES)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|