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
@@ -43,7 +43,7 @@ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOp
43
43
  initialValue: true,
44
44
  })
45
45
  if (isCancel(proceed) || proceed === false) {
46
- log.info(`Skipping. Run ${c.bold('typeclaw role claim')} later when you're ready.`)
46
+ warnMuteUntilClaimed()
47
47
  return
48
48
  }
49
49
 
@@ -78,9 +78,27 @@ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOp
78
78
  }
79
79
  if (result.kind === 'error') {
80
80
  s.stop(c.red(`Claim failed: ${result.payload.reason}`))
81
- log.info(`You can retry with ${c.bold('typeclaw role claim')} anytime.`)
81
+ warnMuteUntilClaimed()
82
82
  return
83
83
  }
84
84
  s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
85
- log.info(`Run ${c.bold('typeclaw role claim')} when you're ready.`)
85
+ warnMuteUntilClaimed()
86
+ }
87
+
88
+ // Printed on every path that finishes init WITHOUT a successful owner claim.
89
+ // Since the scoped-by-default change removed the `member: ["*"]` seeding, an
90
+ // unclaimed chat agent resolves every inbound to `guest` and drops it — the
91
+ // agent answers no one. This is a footgun unless the operator is told plainly,
92
+ // so the warning is loud and names the exact recovery command.
93
+ function warnMuteUntilClaimed(): void {
94
+ note(
95
+ [
96
+ `Your agent will respond to ${c.bold('no one')} until you claim the owner role —`,
97
+ `every inbound message is currently dropped.`,
98
+ '',
99
+ `Run ${c.bold('typeclaw role claim')} to pair yourself, then grant others`,
100
+ `with the agent (ask it: "respond to <user>") or by editing typeclaw.json#roles.`,
101
+ ].join('\n'),
102
+ c.yellow('Heads up: agent is muted'),
103
+ )
86
104
  }
@@ -20,6 +20,8 @@ export function originLabel(origin: MinimalSessionOrigin): string {
20
20
  return `Subagent ${origin.subagent} ← ${shortSessionId(origin.parentSessionId)}`
21
21
  case 'channel':
22
22
  return channelLabel(origin)
23
+ case 'system':
24
+ return `System ${origin.component}`
23
25
  }
24
26
  }
25
27
 
@@ -239,7 +239,12 @@ function messageEndToEvents(
239
239
  return event
240
240
  }
241
241
  if (payload.errorMessage !== undefined) {
242
- return { cat: 'error', ts, message: payload.errorMessage }
242
+ return {
243
+ cat: 'error',
244
+ ts,
245
+ message: payload.errorMessage,
246
+ ...(payload.stopReason !== undefined ? { stopReason: payload.stopReason } : {}),
247
+ }
243
248
  }
244
249
  if (payload.usage !== undefined && (payload.usage.totalTokens > 0 || payload.stopReason !== undefined)) {
245
250
  return {
@@ -39,6 +39,7 @@ function renderTag(event: InspectEvent, opts: RenderOptions): string {
39
39
  case 'tool':
40
40
  return tint(opts, 'yellow', padEnd(event.phase === 'start' ? 'tool ▸' : 'tool ◂', 9))
41
41
  case 'error':
42
+ if (event.stopReason === 'aborted') return tint(opts, 'yellow', padEnd('abort', 9))
42
43
  return tint(opts, 'red', padEnd('error', 9))
43
44
  case 'done':
44
45
  return tint(opts, 'gray', padEnd('done', 9))
@@ -73,8 +74,13 @@ function renderBody(event: InspectEvent, opts: RenderOptions): string {
73
74
  const result = renderResult(event.result, opts.maxTextLength ?? DEFAULT_MAX_TEXT)
74
75
  return `${event.name} → ${status}${result ? ` ${result}` : ''} (${dur})`
75
76
  }
76
- case 'error':
77
- return tint(opts, 'red', truncate(singleLine(event.message), opts.maxTextLength ?? DEFAULT_MAX_TEXT))
77
+ case 'error': {
78
+ const aborted = event.stopReason === 'aborted'
79
+ const text = truncate(singleLine(event.message), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
80
+ const suffix =
81
+ event.stopReason !== undefined && !aborted ? ` ${tint(opts, 'dim', `(stop=${event.stopReason})`)}` : ''
82
+ return `${tint(opts, aborted ? 'yellow' : 'red', text)}${suffix}`
83
+ }
78
84
  case 'done':
79
85
  return renderDone(event, opts)
80
86
  case 'broadcast':
@@ -137,7 +137,12 @@ function* assistantEvents(
137
137
  }
138
138
  }
139
139
  if (typeof message.errorMessage === 'string' && message.errorMessage !== '') {
140
- yield { cat: 'error', ts, message: message.errorMessage }
140
+ yield {
141
+ cat: 'error',
142
+ ts,
143
+ message: message.errorMessage,
144
+ ...(typeof message.stopReason === 'string' ? { stopReason: message.stopReason } : {}),
145
+ }
141
146
  }
142
147
  const usage = readUsage(message.usage)
143
148
  if (usage !== null && (usage.totalTokens > 0 || typeof message.stopReason === 'string')) {
@@ -37,7 +37,10 @@ export type InspectEvent =
37
37
  isError?: boolean
38
38
  durationMs?: number
39
39
  }
40
- | { cat: 'error'; ts: number; message: string }
40
+ // `stopReason` is the upstream-reported reason for the failed/aborted turn
41
+ // (e.g. 'error', 'aborted'). An 'aborted' stopReason is a user cancel, not a
42
+ // provider failure, and is rendered distinctly (see render.ts).
43
+ | { cat: 'error'; ts: number; message: string; stopReason?: string }
41
44
  | {
42
45
  cat: 'done'
43
46
  ts: number
@@ -10,12 +10,26 @@ export const BUILTIN_ROLE_NAMES: readonly BuiltinRoleName[] = ['owner', 'trusted
10
10
  // set at boot via expandOwnerWildcard.
11
11
  export const CORE_PERMISSIONS = {
12
12
  channelRespond: 'channel.respond',
13
+ // Distinct from channelRespond so a respond-capable guest cannot abort
14
+ // other speakers' sessions. The /stop command (both the text-prefix and
15
+ // native-slash paths in the router) gates on this, not on channelRespond:
16
+ // an operator may grant guest channelRespond to let strangers drive masked
17
+ // turns, but session lifecycle control stays member-and-up.
18
+ sessionControl: 'session.control',
13
19
  cronSchedule: 'cron.schedule',
14
20
  cronModify: 'cron.modify',
15
21
  subagentSpawn: 'subagent.spawn',
16
22
  subagentCancel: 'subagent.cancel',
17
23
  subagentOutput: 'subagent.output',
18
24
  subagentSpawnOperator: 'subagent.spawn.operator',
25
+ // Phrased as capabilities to SEE, not to hide, so the role tower stays
26
+ // monotonic (a higher tier sees a strict superset of a lower tier).
27
+ // resolveHiddenPaths masks whatever the resolved role lacks: fsSeePrivate
28
+ // gates workspace/+memory/+sessions/, fsSeeSecrets gates .env+secrets.json.
29
+ // The fail-safe floor is the undefined origin (see has() in
30
+ // permissions.ts), not the guest role, which is now grantable.
31
+ fsSeePrivate: 'fs.see.private',
32
+ fsSeeSecrets: 'fs.see.secrets',
19
33
  } as const
20
34
 
21
35
  // Sentinel that `expandOwnerWildcard` swaps for the concrete union of
@@ -55,12 +69,15 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
55
69
  match: [{ kind: 'tui' }],
56
70
  permissions: [
57
71
  CORE_PERMISSIONS.channelRespond,
72
+ CORE_PERMISSIONS.sessionControl,
58
73
  CORE_PERMISSIONS.cronSchedule,
59
74
  CORE_PERMISSIONS.cronModify,
60
75
  CORE_PERMISSIONS.subagentSpawn,
61
76
  CORE_PERMISSIONS.subagentCancel,
62
77
  CORE_PERMISSIONS.subagentOutput,
63
78
  CORE_PERMISSIONS.subagentSpawnOperator,
79
+ CORE_PERMISSIONS.fsSeePrivate,
80
+ CORE_PERMISSIONS.fsSeeSecrets,
64
81
  'security.bypass.low',
65
82
  'security.bypass.medium',
66
83
  'security.bypass.high',
@@ -71,11 +88,14 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
71
88
  match: [],
72
89
  permissions: [
73
90
  CORE_PERMISSIONS.channelRespond,
91
+ CORE_PERMISSIONS.sessionControl,
74
92
  CORE_PERMISSIONS.cronSchedule,
75
93
  CORE_PERMISSIONS.subagentSpawn,
76
94
  CORE_PERMISSIONS.subagentCancel,
77
95
  CORE_PERMISSIONS.subagentOutput,
78
96
  CORE_PERMISSIONS.subagentSpawnOperator,
97
+ CORE_PERMISSIONS.fsSeePrivate,
98
+ CORE_PERMISSIONS.fsSeeSecrets,
79
99
  'security.bypass.low',
80
100
  'security.bypass.medium',
81
101
  ],
@@ -84,9 +104,11 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
84
104
  match: [],
85
105
  permissions: [
86
106
  CORE_PERMISSIONS.channelRespond,
107
+ CORE_PERMISSIONS.sessionControl,
87
108
  CORE_PERMISSIONS.subagentSpawn,
88
109
  CORE_PERMISSIONS.subagentCancel,
89
110
  CORE_PERMISSIONS.subagentOutput,
111
+ CORE_PERMISSIONS.fsSeePrivate,
90
112
  'security.bypass.low',
91
113
  ],
92
114
  },
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ import { commitSystemFileSync } from '@/git/system-commit'
5
+
6
+ import { BUILTIN_ROLES, isBuiltinRoleName } from './builtins'
4
7
  import { parseMatchRule } from './match-rule'
5
8
 
6
9
  // Appends `rule` to `typeclaw.json#roles.<name>.match`, creating the role
@@ -23,15 +26,97 @@ export type GrantOptions = {
23
26
  matchRule: string
24
27
  }
25
28
 
29
+ export type GrantPermissionOptions = {
30
+ cwd: string
31
+ roleName: string
32
+ permission: string
33
+ }
34
+
26
35
  export function grantRole(opts: GrantOptions): GrantResult {
27
36
  const validation = parseMatchRule(opts.matchRule)
28
37
  if (!validation.ok) {
29
38
  return { ok: false, reason: `invalid match rule '${opts.matchRule}': ${validation.error}` }
30
39
  }
31
40
 
32
- const path = join(opts.cwd, CONFIG_FILE)
41
+ const loaded = loadConfigObject(opts.cwd, opts.roleName)
42
+ if (!loaded.ok) return loaded
43
+
44
+ const { obj, roles, role } = loaded
45
+ const existingMatch = Array.isArray(role.match) ? [...(role.match as unknown[])] : []
46
+
47
+ // Dedup by exact string equality — match rules canonicalize during load,
48
+ // and a literal duplicate in the file is a noise source the schema doesn't
49
+ // currently dedupe for us.
50
+ if (existingMatch.includes(opts.matchRule)) {
51
+ return { ok: true, added: false }
52
+ }
53
+
54
+ role.match = [...existingMatch, opts.matchRule]
55
+ roles[opts.roleName] = role
56
+ obj.roles = roles
57
+
58
+ const written = writeConfigObject(opts.cwd, obj)
59
+ if (!written.ok) return written
60
+
61
+ // Best-effort commit so a claimed role survives a fresh clone/rebuild — a
62
+ // failing commit leaves the on-disk grant intact (history, not correctness).
63
+ // Subject stays neutral: every grantRole caller hits this, not just claim.
64
+ commitSystemFileSync(opts.cwd, CONFIG_FILE, `${CONFIG_FILE}: grant ${opts.roleName} role`)
65
+
66
+ return written
67
+ }
68
+
69
+ // Appends `permission` to `typeclaw.json#roles.<name>.permissions`. For a
70
+ // built-in role with no explicit `permissions[]`, the runtime treats the
71
+ // field as "use built-in defaults" (see resolveOne in permissions.ts), so a
72
+ // naive write of `[permission]` would NARROW the role to a single capability,
73
+ // silently dropping its defaults. We materialize the current effective set
74
+ // (explicit field if present, else the built-in default list) and append to
75
+ // THAT, preserving every existing capability. Idempotent: a permission the
76
+ // role already holds is a no-op.
77
+ //
78
+ // NOTE: `roles.permissions` is `restart-required` (FIELD_EFFECTS); the write
79
+ // lands on disk but does not take effect until the next container restart.
80
+ // Callers must surface that to the operator.
81
+ export function grantRolePermission(opts: GrantPermissionOptions): GrantResult {
82
+ const loaded = loadConfigObject(opts.cwd, opts.roleName)
83
+ if (!loaded.ok) return loaded
84
+
85
+ const { obj, roles, role } = loaded
86
+ const effective = effectivePermissions(opts.roleName, role)
87
+
88
+ if (effective.includes(opts.permission)) {
89
+ return { ok: true, added: false }
90
+ }
91
+
92
+ role.permissions = [...effective, opts.permission]
93
+ roles[opts.roleName] = role
94
+ obj.roles = roles
95
+
96
+ return writeConfigObject(opts.cwd, obj)
97
+ }
98
+
99
+ function effectivePermissions(roleName: string, role: Record<string, unknown>): string[] {
100
+ if (Array.isArray(role.permissions)) {
101
+ return role.permissions.filter((p): p is string => typeof p === 'string')
102
+ }
103
+ if (isBuiltinRoleName(roleName)) {
104
+ return [...BUILTIN_ROLES[roleName].permissions]
105
+ }
106
+ return []
107
+ }
108
+
109
+ type LoadedConfig = {
110
+ ok: true
111
+ obj: Record<string, unknown>
112
+ roles: Record<string, unknown>
113
+ role: Record<string, unknown>
114
+ }
115
+
116
+ function loadConfigObject(cwd: string, roleName: string): LoadedConfig | { ok: false; reason: string } {
117
+ const path = join(cwd, CONFIG_FILE)
33
118
  if (!existsSync(path)) {
34
- return { ok: false, reason: `${CONFIG_FILE} not found at ${opts.cwd}` }
119
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}` }
35
120
  }
36
121
 
37
122
  let raw: string
@@ -54,26 +139,17 @@ export function grantRole(opts: GrantOptions): GrantResult {
54
139
 
55
140
  const obj = json as Record<string, unknown>
56
141
  const roles = isPlainObject(obj.roles) ? { ...obj.roles } : {}
57
- const role = isPlainObject(roles[opts.roleName]) ? { ...(roles[opts.roleName] as Record<string, unknown>) } : {}
58
- const existingMatch = Array.isArray(role.match) ? [...(role.match as unknown[])] : []
59
-
60
- // Dedup by exact string equality — match rules canonicalize during load,
61
- // and a literal duplicate in the file is a noise source the schema doesn't
62
- // currently dedupe for us.
63
- if (existingMatch.includes(opts.matchRule)) {
64
- return { ok: true, added: false }
65
- }
66
-
67
- role.match = [...existingMatch, opts.matchRule]
68
- roles[opts.roleName] = role
69
- obj.roles = roles
142
+ const role = isPlainObject(roles[roleName]) ? { ...(roles[roleName] as Record<string, unknown>) } : {}
143
+ return { ok: true, obj, roles, role }
144
+ }
70
145
 
146
+ function writeConfigObject(cwd: string, obj: Record<string, unknown>): GrantResult {
147
+ const path = join(cwd, CONFIG_FILE)
71
148
  try {
72
149
  writeAtomic(path, `${JSON.stringify(obj, null, 2)}\n`)
73
150
  } catch (error) {
74
151
  return { ok: false, reason: `failed to write ${CONFIG_FILE}: ${describeError(error)}` }
75
152
  }
76
-
77
153
  return { ok: true, added: true }
78
154
  }
79
155
 
@@ -24,6 +24,12 @@ export {
24
24
  type PermissionService,
25
25
  type UnknownPermissionWarning,
26
26
  } from './permissions'
27
- export { grantRole, type GrantOptions, type GrantResult } from './grant'
28
- export { matchesOrigin, type MatchableOrigin } from './resolve'
27
+ export {
28
+ grantRole,
29
+ grantRolePermission,
30
+ type GrantOptions,
31
+ type GrantPermissionOptions,
32
+ type GrantResult,
33
+ } from './grant'
34
+ export { matchesOrigin, isDmChannelOrigin, type MatchableOrigin } from './resolve'
29
35
  export { MATCH_RULE_JSON_SCHEMA_PATTERN, rolesConfigSchema, type RoleConfig, type RolesConfig } from './schema'
@@ -110,6 +110,13 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
110
110
  function resolveRole(origin: SessionOrigin | undefined): string {
111
111
  if (origin === undefined) return 'guest'
112
112
 
113
+ // Runtime-owned infrastructure (memory, backup) acts on the operator's
114
+ // behalf over operator-owned state. It is constructed only by runtime/
115
+ // bundled code — inbound channel/cron content cannot produce this kind —
116
+ // so resolving it to owner is not a laundering vector. See the `system`
117
+ // origin doc in session-origin.ts.
118
+ if (origin.kind === 'system') return 'owner'
119
+
113
120
  if (origin.kind === 'cron') {
114
121
  const role = origin.scheduledByRole
115
122
  if (role !== undefined && byName.has(role)) return role
@@ -134,6 +141,15 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
134
141
 
135
142
  return {
136
143
  has(origin, permission) {
144
+ // Fail-safe floor: an undefined origin holds nothing, regardless of
145
+ // role permissions. Previously this floor was implicit in `guest`
146
+ // being empty; now `guest` is grantable (an operator may grant it
147
+ // `channel.respond`), so the floor must live on the no-actor input
148
+ // instead. Keeps every downstream `has(maybeUndefined, ...)` check
149
+ // (bypass tiers, fs.see.*, subagent spawn) closed even after a guest
150
+ // grant. resolveRole/describe still report 'guest' for audit/display;
151
+ // only authorization is forced closed.
152
+ if (origin === undefined) return false
137
153
  const roleName = resolveRole(origin)
138
154
  const role = byName.get(roleName)
139
155
  if (!role) return false
@@ -79,3 +79,13 @@ function matchesBucket(
79
79
  if (platform === 'telegram') return origin.workspace === 'dm' || origin.workspace.startsWith('@')
80
80
  return false
81
81
  }
82
+
83
+ // True only for a 1:1 direct-message channel origin (a two-participant
84
+ // conversation: the principal + the bot). Reuses the same per-adapter
85
+ // workspace markers as the `dm` match-rule bucket. A DM is the only channel
86
+ // shape with no third-party content buffered into a turn, so role-grant tools
87
+ // treat an owner/trusted DM as injection-equivalent to the TUI; group/open
88
+ // channels never qualify.
89
+ export function isDmChannelOrigin(origin: { adapter: AdapterId; workspace: string }): boolean {
90
+ return matchesBucket('dm', { kind: 'channel', adapter: origin.adapter, workspace: origin.workspace, chat: '' })
91
+ }
@@ -250,6 +250,18 @@ export type SpawnSubagentOptions = {
250
250
  // `spawnedByOrigin: event.origin`. The runtime resolves `spawnedByRole`
251
251
  // from the origin via the PermissionService, so the spawning session's
252
252
  // role is inherited rather than forged from outside.
253
+ //
254
+ // TRUST MODEL: `spawnedByOrigin` accepts any SessionOrigin, including
255
+ // `{ kind: 'system' }`, which resolves to owner. That is intentional and
256
+ // not an escalation path: a plugin is a full-trust, in-process module with
257
+ // no sandbox (see AGENTS.md / docs/internals/skills) — it can already do
258
+ // anything the runtime can, so minting a system origin grants it nothing it
259
+ // lacks. The anti-forgery guarantee this API preserves is narrower and
260
+ // unaffected: inbound channel/cron CONTENT can never reach owner, because
261
+ // those origins are constructed by the runtime from the transport, never
262
+ // from message text, and a content-driven turn cannot produce a `system`
263
+ // origin. Bundled infra (memory, backup) uses `system` to act on the
264
+ // operator's own state; third-party plugins should pass `event.origin`.
253
265
  parentSessionId?: string
254
266
  spawnedByOrigin?: SessionOrigin
255
267
  }
@@ -10,6 +10,7 @@ export {
10
10
  type CreateClaimControllerOptions,
11
11
  } from './controller'
12
12
  export { formatClaimMatchRule, type PartialChannelOrigin } from './match-rule'
13
+ export { reloadAfterClaim, type ReloadAfterClaimOptions, type ReloadAfterClaimResult } from './reload-after-claim'
13
14
  export {
14
15
  createPendingClaimRegistry,
15
16
  type ClaimResult,
@@ -0,0 +1,34 @@
1
+ import { requestReload, type ReloadResult } from '@/reload'
2
+
3
+ export interface ReloadAfterClaimOptions {
4
+ url: string
5
+ reload?: (opts: { url: string; scope: string }) => Promise<ReloadResult[]>
6
+ }
7
+
8
+ export type ReloadAfterClaimResult = { ok: true; results: ReloadResult[] } | { ok: false; reason: string }
9
+
10
+ // Best-effort by contract: the role is already persisted to typeclaw.json by
11
+ // the time a claim completes, so a reload failure here must NOT fail the claim.
12
+ // Callers surface the reason but keep the claim successful.
13
+ export async function reloadAfterClaim(opts: ReloadAfterClaimOptions): Promise<ReloadAfterClaimResult> {
14
+ const reload = opts.reload ?? requestReload
15
+ let results: ReloadResult[]
16
+ try {
17
+ results = await reload({ url: opts.url, scope: 'config' })
18
+ } catch (err) {
19
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
20
+ }
21
+
22
+ // requestReload resolves even when the server reports a per-scope failure, so
23
+ // an exception-free call is not proof the config actually reloaded. Surface
24
+ // any failed scope (and an empty result, which means nothing reloaded) as a
25
+ // failure so the caller can tell the user to reload manually.
26
+ const failed = results.filter((r) => !r.ok)
27
+ if (failed.length > 0) {
28
+ return { ok: false, reason: failed.map((r) => `${r.scope}: ${r.reason}`).join('; ') }
29
+ }
30
+ if (results.length === 0) {
31
+ return { ok: false, reason: 'no reloadable config scope responded' }
32
+ }
33
+ return { ok: true, results }
34
+ }
@@ -7,7 +7,7 @@ import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagen
7
7
  import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
8
8
  import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
9
9
  import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
10
- import type { PermissionService } from '@/permissions'
10
+ import type { PermissionService, RolesConfig } from '@/permissions'
11
11
  import type { ReloadRegistry } from '@/reload'
12
12
  import type { SessionFactory } from '@/sessions'
13
13
  import type { Stream } from '@/stream'
@@ -47,6 +47,10 @@ export type BuildChannelSessionFactoryDeps = {
47
47
  // the production wiring can plumb in pluginsLoaded.permissions while tests
48
48
  // (or stand-alone callers) keep the previous no-annotation behavior.
49
49
  permissions?: PermissionService
50
+ // Re-reads roles from disk for the grant_role tool's hot-reload (reload then
51
+ // read, not an in-memory snapshot). Forwarded to createSession; the tool only
52
+ // mounts when permissions is also present.
53
+ reloadRoles?: () => RolesConfig | undefined
50
54
  // Test seam: lets a fake stand in for the agent session creator so tests
51
55
  // can assert exactly which CreateSessionOptions the factory builds without
52
56
  // needing a live LLM, plugin runtime, or session manager on disk.
@@ -124,6 +128,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
124
128
  ...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
125
129
  ...(deps.runtimeVersion !== undefined ? { runtimeVersion: deps.runtimeVersion } : {}),
126
130
  ...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
131
+ ...(deps.reloadRoles !== undefined ? { reloadRoles: deps.reloadRoles } : {}),
127
132
  ...(deps.liveSubagentRegistry !== undefined ? { liveSubagentRegistry: deps.liveSubagentRegistry } : {}),
128
133
  ...(deps.subagentRegistry !== undefined ? { subagentRegistry: deps.subagentRegistry } : {}),
129
134
  ...(deps.getCreateSessionForSubagent !== undefined
package/src/run/index.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  type SubagentCompletionBridge,
25
25
  } from '@/channels'
26
26
  import { createTunnelBridge, type TunnelBridge } from '@/channels/tunnel-bridge'
27
- import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync } from '@/config'
27
+ import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync, reloadConfig } from '@/config'
28
28
  import {
29
29
  type CronConsumer,
30
30
  type CronJob,
@@ -150,6 +150,7 @@ export async function startAgent({
150
150
  createConfigReloadable({
151
151
  cwd,
152
152
  permissions: pluginsLoaded.permissions,
153
+ onRolesChanged: () => channelManager.router.tearDownAllLive(),
153
154
  skipMountValidation: containerName !== undefined,
154
155
  }),
155
156
  )
@@ -244,6 +245,7 @@ export async function startAgent({
244
245
  getChannelRouter: () => channelManager.router,
245
246
  rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
246
247
  permissions: pluginsLoaded.permissions,
248
+ reloadRoles: () => reloadRolesFromDisk(cwd),
247
249
  liveSubagentRegistry,
248
250
  liveSessionRegistry,
249
251
  subagentRegistry: pluginRuntime.get().subagents,
@@ -688,6 +690,21 @@ async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<
688
690
  await Promise.allSettled(all.map((m) => m.dispose()))
689
691
  }
690
692
 
693
+ // grant_role's hot-reload hook: reload the live config FROM DISK (grantRole
694
+ // wrote typeclaw.json directly, bypassing the in-memory snapshot) and return
695
+ // the fresh roles for permissions.replaceRoles. Mirrors the config reloadable's
696
+ // reload-then-read order. Falls back to the current snapshot if the just-written
697
+ // file fails to parse — the on-disk write still stands and the next reload picks
698
+ // it up; replaceRoles with stale roles is no worse than not reloading.
699
+ function reloadRolesFromDisk(cwd: string): ReturnType<typeof getConfig>['roles'] {
700
+ try {
701
+ reloadConfig(cwd)
702
+ } catch {
703
+ // keep the current pointer; see above
704
+ }
705
+ return getConfig().roles
706
+ }
707
+
691
708
  async function startScheduler({
692
709
  cwd,
693
710
  loadCron,
@@ -13,6 +13,11 @@ export type SandboxedCommand = {
13
13
  commandString: string
14
14
  }
15
15
 
16
+ // Fixed fd the rendered commandString opens to /dev/null for --ro-bind-data
17
+ // file masks. 3 is the first fd above stdio; the bash tool's spawn does not
18
+ // inherit it, so the redirect is part of the command string itself.
19
+ const MASK_DATA_FD = 3
20
+
16
21
  // Pure: no I/O, no bwrap availability probe (that is `ensureBwrapAvailable`'s
17
22
  // job). Given a bash command and a policy, returns the bwrap-wrapped argv plus
18
23
  // a shell-quoted rendering of it. Knows nothing about subagents, origins, or
@@ -24,7 +29,9 @@ export function buildSandboxedCommand(command: string, policy: SandboxPolicy = {
24
29
  applyCommandFilter(command, policy.commandFilter)
25
30
  }
26
31
  const argv = buildArgv(command, policy)
27
- return { argv, commandString: formatCommand(argv) }
32
+ const needsMaskFd = (policy.masks?.files?.length ?? 0) > 0
33
+ const commandString = needsMaskFd ? `${formatCommand(argv)} ${MASK_DATA_FD}</dev/null` : formatCommand(argv)
34
+ return { argv, commandString }
28
35
  }
29
36
 
30
37
  function buildArgv(command: string, policy: SandboxPolicy): string[] {
@@ -58,6 +65,38 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
58
65
 
59
66
  argv.push('--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc', '--dev', '/dev', '--tmpfs', '/tmp')
60
67
 
68
+ // Recreate the usr-merge root symlinks that --ro-bind /usr does NOT bring
69
+ // along. On the Debian base (oven/bun:1-slim) /bin /sbin /lib /lib64 are
70
+ // root-level symlinks into /usr; binding /usr exposes /usr/bin etc. but the
71
+ // root entries themselves are absent in the sandbox. That breaks every
72
+ // ABSOLUTE-path reference the kernel resolves WITHOUT consulting PATH:
73
+ // - ELF interpreter (the dynamic loader) baked into PT_INTERP:
74
+ // /lib/ld-linux-aarch64.so.1 (arm64) or /lib64/ld-linux-x86-64.so.2
75
+ // (amd64). Missing it makes bwrap report "execvp bash: No such file or
76
+ // directory" — the missing file is the loader, not bash.
77
+ // - shebang lines: /bin/sh and /bin/bash are the most common interpreters
78
+ // on earth; a script with "#!/bin/sh" fails "cannot execute: required
79
+ // file not found" without /bin, even though /usr/bin/sh exists, because
80
+ // the shebang path is literal and skips PATH.
81
+ // --ro-bind-try, not --ro-bind: the set is arch- and base-dependent (arm64
82
+ // oven/bun:1-slim ships /lib but no /lib64), and a hard bind of an absent
83
+ // source aborts bwrap. -try binds each only when present, keeping this
84
+ // builder pure (no host filesystem probe) and correct across arches/bases.
85
+ argv.push(
86
+ '--ro-bind-try',
87
+ '/bin',
88
+ '/bin',
89
+ '--ro-bind-try',
90
+ '/sbin',
91
+ '/sbin',
92
+ '--ro-bind-try',
93
+ '/lib',
94
+ '/lib',
95
+ '--ro-bind-try',
96
+ '/lib64',
97
+ '/lib64',
98
+ )
99
+
61
100
  if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
62
101
  // --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
63
102
  // mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
@@ -70,6 +109,8 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
70
109
  appendMount(argv, mount)
71
110
  }
72
111
 
112
+ appendMasks(argv, policy)
113
+
73
114
  if (policy.cwd !== undefined) {
74
115
  argv.push('--chdir', policy.cwd)
75
116
  }
@@ -78,6 +119,15 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
78
119
  return argv
79
120
  }
80
121
 
122
+ function appendMasks(argv: string[], policy: SandboxPolicy): void {
123
+ for (const dir of policy.masks?.dirs ?? []) {
124
+ argv.push('--tmpfs', dir)
125
+ }
126
+ for (const file of policy.masks?.files ?? []) {
127
+ argv.push('--ro-bind-data', String(MASK_DATA_FD), file)
128
+ }
129
+ }
130
+
81
131
  function appendMount(argv: string[], mount: SandboxMount): void {
82
132
  switch (mount.type) {
83
133
  case 'ro-bind':
@@ -0,0 +1,41 @@
1
+ import { join } from 'node:path'
2
+
3
+ import type { SessionOrigin } from '@/agent/session-origin'
4
+ import { CORE_PERMISSIONS } from '@/permissions/builtins'
5
+ import type { PermissionService } from '@/permissions/permissions'
6
+
7
+ export type HiddenPaths = {
8
+ dirs: string[]
9
+ files: string[]
10
+ }
11
+
12
+ const PRIVATE_DIRS = ['workspace', 'memory', 'sessions'] as const
13
+ const SECRET_FILES = ['.env', 'secrets.json'] as const
14
+
15
+ // The agent's private working surface and credential files are masked from
16
+ // sandboxed bash unless the resolved role carries the matching fs.see.* grant.
17
+ // `permissions.has` resolves the role from the live origin and fails safe to
18
+ // guest (empty permissions) for an unclear/undefined origin, so a missing
19
+ // grant — whether from a low tier or an unresolvable author — hides the path.
20
+ //
21
+ // The security.bypass.* fallback keeps custom roles (which may never name the
22
+ // fs.see.* strings) working by capability: a role trusted enough to bypass
23
+ // medium-severity guards is treated as trusted for filesystem visibility, and
24
+ // bypass.low maps to the private-surface tier. fs.see.* always wins when
25
+ // present; the fallback only fires when it is absent.
26
+ export function resolveHiddenPaths(
27
+ permissions: PermissionService,
28
+ origin: SessionOrigin | undefined,
29
+ agentDir: string,
30
+ ): HiddenPaths {
31
+ const seesPrivate =
32
+ permissions.has(origin, CORE_PERMISSIONS.fsSeePrivate) ||
33
+ permissions.has(origin, 'security.bypass.low') ||
34
+ permissions.has(origin, 'security.bypass.medium')
35
+ const seesSecrets =
36
+ permissions.has(origin, CORE_PERMISSIONS.fsSeeSecrets) || permissions.has(origin, 'security.bypass.medium')
37
+
38
+ const dirs = seesPrivate ? [] : PRIVATE_DIRS.map((d) => join(agentDir, d))
39
+ const files = seesSecrets ? [] : SECRET_FILES.map((f) => join(agentDir, f))
40
+ return { dirs, files }
41
+ }