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
|
@@ -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
|
-
|
|
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
|
-
|
|
81
|
+
warnMuteUntilClaimed()
|
|
82
82
|
return
|
|
83
83
|
}
|
|
84
84
|
s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
|
|
85
|
-
|
|
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
|
}
|
package/src/inspect/label.ts
CHANGED
|
@@ -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
|
|
package/src/inspect/live.ts
CHANGED
|
@@ -239,7 +239,12 @@ function messageEndToEvents(
|
|
|
239
239
|
return event
|
|
240
240
|
}
|
|
241
241
|
if (payload.errorMessage !== undefined) {
|
|
242
|
-
return {
|
|
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 {
|
package/src/inspect/render.ts
CHANGED
|
@@ -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
|
-
|
|
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':
|
package/src/inspect/replay.ts
CHANGED
|
@@ -137,7 +137,12 @@ function* assistantEvents(
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
if (typeof message.errorMessage === 'string' && message.errorMessage !== '') {
|
|
140
|
-
yield {
|
|
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')) {
|
package/src/inspect/types.ts
CHANGED
|
@@ -37,7 +37,10 @@ export type InspectEvent =
|
|
|
37
37
|
isError?: boolean
|
|
38
38
|
durationMs?: number
|
|
39
39
|
}
|
|
40
|
-
|
|
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
|
},
|
package/src/permissions/grant.ts
CHANGED
|
@@ -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
|
|
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 ${
|
|
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[
|
|
58
|
-
|
|
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
|
|
package/src/permissions/index.ts
CHANGED
|
@@ -24,6 +24,12 @@ export {
|
|
|
24
24
|
type PermissionService,
|
|
25
25
|
type UnknownPermissionWarning,
|
|
26
26
|
} from './permissions'
|
|
27
|
-
export {
|
|
28
|
-
|
|
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
|
+
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/role-claim/index.ts
CHANGED
|
@@ -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,
|
package/src/sandbox/build.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|