@ddtcorex/dsh-maestro-guard 0.1.0 → 0.2.1
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/cordis.patch.yml +1 -0
- package/lib/approval-store.d.ts.map +1 -1
- package/lib/approval-store.js.map +1 -1
- package/lib/approve-tool.d.ts +25 -0
- package/lib/approve-tool.d.ts.map +1 -0
- package/lib/approve-tool.js +54 -0
- package/lib/approve-tool.js.map +1 -0
- package/lib/full-scan-tool.d.ts +7 -0
- package/lib/full-scan-tool.d.ts.map +1 -0
- package/lib/full-scan-tool.js +79 -0
- package/lib/full-scan-tool.js.map +1 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +144 -2
- package/lib/index.js.map +1 -1
- package/lib/pending.d.ts +44 -0
- package/lib/pending.d.ts.map +1 -0
- package/lib/pending.js +133 -0
- package/lib/pending.js.map +1 -0
- package/lib/permission-policy.d.ts.map +1 -1
- package/lib/permission-policy.js.map +1 -1
- package/lib/sandbox.d.ts +95 -0
- package/lib/sandbox.d.ts.map +1 -0
- package/lib/sandbox.js +321 -0
- package/lib/sandbox.js.map +1 -0
- package/lib/secret-redactor.d.ts.map +1 -1
- package/lib/secret-redactor.js.map +1 -1
- package/package.json +6 -1
- package/src/host/approve-tool.ts +61 -0
- package/src/host/full-scan-tool.ts +71 -0
- package/src/host/index.ts +184 -0
- package/src/host/pending.ts +144 -0
- package/src/host/sandbox.ts +322 -0
- package/src/sandbox.ts +5 -0
- package/src/index.ts +0 -38
- /package/src/{approval-store.ts → host/approval-store.ts} +0 -0
- /package/src/{augment.d.ts → host/augment.d.ts} +0 -0
- /package/src/{permission-policy.ts → host/permission-policy.ts} +0 -0
- /package/src/{secret-redactor.ts → host/secret-redactor.ts} +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import { ApprovalStore } from './approval-store.js'
|
|
4
|
+
import { PermissionPolicy } from './permission-policy.js'
|
|
5
|
+
import { PendingStore, ticketHash } from './pending.js'
|
|
6
|
+
import { containsSecret, redact } from './secret-redactor.js'
|
|
7
|
+
import { checkSandbox, extractCommandText, isBlockedCommand, isBlockedGitCommand, resolveCurrentBranch } from './sandbox.js'
|
|
8
|
+
import { apply as applyFullScan } from './full-scan-tool.js'
|
|
9
|
+
import { applyApproveTools } from './approve-tool.js'
|
|
10
|
+
import type { GuardToolExecution, GuardPreToolDecision } from './augment.js'
|
|
11
|
+
|
|
12
|
+
function getCurrentBranch(cwd?: string): string | undefined {
|
|
13
|
+
if (!cwd) return undefined
|
|
14
|
+
try {
|
|
15
|
+
return execSync('git branch --show-current', { cwd, timeout: 800, encoding: 'utf-8' }).trim() || undefined
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getSessionCwd(exec: unknown): string | undefined {
|
|
22
|
+
const e: any = exec as any
|
|
23
|
+
return (
|
|
24
|
+
e?.agent?.session?.header?.cwd ??
|
|
25
|
+
e?.session?.header?.cwd ??
|
|
26
|
+
e?.header?.cwd ??
|
|
27
|
+
e?.cwd ??
|
|
28
|
+
undefined
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readGuardConfig(): Promise<Record<string, unknown>> {
|
|
33
|
+
try {
|
|
34
|
+
const mod: any = await import('@ddtcorex/dsh-maestro-config-lib')
|
|
35
|
+
if (typeof mod.load === 'function') {
|
|
36
|
+
try {
|
|
37
|
+
const doc = await mod.load()
|
|
38
|
+
if (doc?.domains?.guard && typeof doc.domains.guard === 'object' && !Array.isArray(doc.domains.guard)) {
|
|
39
|
+
return doc.domains.guard as Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
if (typeof mod.get === 'function') {
|
|
44
|
+
try {
|
|
45
|
+
const g = await mod.get('guard')
|
|
46
|
+
if (g && typeof g === 'object' && !Array.isArray(g)) return g as Record<string, unknown>
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
if (typeof mod.readFlat === 'function') {
|
|
50
|
+
try {
|
|
51
|
+
const flat = await mod.readFlat()
|
|
52
|
+
if (flat && typeof flat === 'object' && (flat as any).guard && typeof (flat as any).guard === 'object') {
|
|
53
|
+
return (flat as any).guard as Record<string, unknown>
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createGuardHandler(
|
|
62
|
+
store: ApprovalStore,
|
|
63
|
+
policy: PermissionPolicy,
|
|
64
|
+
pending: PendingStore,
|
|
65
|
+
readConfig: () => Promise<Record<string, unknown>> = readGuardConfig,
|
|
66
|
+
) {
|
|
67
|
+
return async (exec: GuardToolExecution, next: () => Promise<GuardPreToolDecision>): Promise<GuardPreToolDecision> => {
|
|
68
|
+
const tool = (exec as GuardToolExecution).name ?? (exec as GuardToolExecution).tool ?? ''
|
|
69
|
+
const rawArgs = (exec as any)?.args ?? (exec as any)?.arguments
|
|
70
|
+
|
|
71
|
+
// Sandbox hard gate: credential paths, ~/.cloudflared, NPM_TOKEN, git-protection, publish, cwd (via checkSandbox)
|
|
72
|
+
const cwd = getSessionCwd(exec)
|
|
73
|
+
// Branch detection follows the repo the command actually targets (cd / git -C),
|
|
74
|
+
// not the session cwd — a session whose cwd repo sits on master must not block
|
|
75
|
+
// feature-branch pushes inside sub-repos (fix/guard-protection-precision).
|
|
76
|
+
const commandText = extractCommandText(rawArgs)
|
|
77
|
+
// Resolve the current branch lazily, only when the executed command could
|
|
78
|
+
// plausibly be a git/gh operation — otherwise every tool call (reads,
|
|
79
|
+
// writes, memory, unrelated bash) would spawn `git branch --show-current`
|
|
80
|
+
// for nothing.
|
|
81
|
+
const branchRelevant = commandText != null && /\b(git|gh)\b/i.test(commandText)
|
|
82
|
+
const currentBranch = branchRelevant ? resolveCurrentBranch(commandText, cwd, getCurrentBranch) : undefined
|
|
83
|
+
const asTextForSandbox = rawArgs != null ? JSON.stringify(rawArgs) : ''
|
|
84
|
+
const combinedForCheck = `${tool} ${asTextForSandbox}`
|
|
85
|
+
// Read guard config at runtime (injected lists) — fallback to defaults when empty
|
|
86
|
+
const guardCfg = await readConfig().catch(() => ({} as Record<string, unknown>))
|
|
87
|
+
const credentialPaths = Array.isArray((guardCfg as any).credentialPaths) ? (guardCfg as any).credentialPaths as string[] : undefined
|
|
88
|
+
const gitProtection = (guardCfg as any).gitProtection && typeof (guardCfg as any).gitProtection === 'object' ? (guardCfg as any).gitProtection as { enabled: boolean; branches: string[] } : undefined
|
|
89
|
+
const publishBlocked = typeof (guardCfg as any).publishBlocked === 'boolean' ? (guardCfg as any).publishBlocked as boolean : undefined
|
|
90
|
+
const cwdContainment = typeof (guardCfg as any).cwdContainment === 'boolean' ? (guardCfg as any).cwdContainment as boolean : undefined
|
|
91
|
+
|
|
92
|
+
const publishBlockedEffective = publishBlocked ?? true
|
|
93
|
+
const gitEnabled = gitProtection?.enabled ?? true
|
|
94
|
+
const branches = gitProtection?.branches ?? ['master', 'main']
|
|
95
|
+
|
|
96
|
+
const isPublish = publishBlockedEffective && commandText ? isBlockedCommand(commandText) : false
|
|
97
|
+
let approvedForPublish = false
|
|
98
|
+
if (isPublish) {
|
|
99
|
+
approvedForPublish =
|
|
100
|
+
(await store.isApproved('publish')) ||
|
|
101
|
+
(await store.isApproved('pnpm-publish')) ||
|
|
102
|
+
(await store.isApproved('pnpm publish')) ||
|
|
103
|
+
(await store.isApproved(tool))
|
|
104
|
+
}
|
|
105
|
+
const isGitProtected = gitEnabled && commandText ? isBlockedGitCommand(commandText, currentBranch, branches) : false
|
|
106
|
+
let approvedForGit = false
|
|
107
|
+
if (isGitProtected) {
|
|
108
|
+
approvedForGit =
|
|
109
|
+
(await store.isApproved('git-protection')) ||
|
|
110
|
+
(await store.isApproved('publish')) ||
|
|
111
|
+
(await store.isApproved(tool))
|
|
112
|
+
}
|
|
113
|
+
const sandboxRes = checkSandbox(tool, rawArgs, {
|
|
114
|
+
cwd,
|
|
115
|
+
currentBranch,
|
|
116
|
+
approved: isGitProtected ? approvedForGit : approvedForPublish,
|
|
117
|
+
credentialPaths,
|
|
118
|
+
gitProtection,
|
|
119
|
+
publishBlocked,
|
|
120
|
+
cwdContainment,
|
|
121
|
+
})
|
|
122
|
+
if (sandboxRes.blocked) {
|
|
123
|
+
const scope: 'git-protection' | 'publish' = sandboxRes.reason?.includes('publish') ? 'publish' : 'git-protection'
|
|
124
|
+
const sessionId = (exec as any)?.agent?.session?.id ?? undefined
|
|
125
|
+
// Hash over the executed command when available, not the full (tool + args)
|
|
126
|
+
// serialization — cosmetic arg fields (description, timeoutMs) must not
|
|
127
|
+
// mint a fresh ticket for a re-run of the same command (fix/guard-protection-precision).
|
|
128
|
+
const canonical = commandText ?? combinedForCheck
|
|
129
|
+
const cmdText = redact(canonical.slice(0, 300))
|
|
130
|
+
const hash = ticketHash(scope, cmdText)
|
|
131
|
+
const approved = await pending.findApprovedByHash(scope, hash, sessionId)
|
|
132
|
+
if (approved) {
|
|
133
|
+
await pending.consume(approved.id) // exactly one retry passes (consume is mutex-serialized)
|
|
134
|
+
}
|
|
135
|
+
if (!approved) {
|
|
136
|
+
try {
|
|
137
|
+
const req = await pending.record({
|
|
138
|
+
scope,
|
|
139
|
+
tool,
|
|
140
|
+
command: cmdText,
|
|
141
|
+
reason: sandboxRes.reason ?? 'blocked',
|
|
142
|
+
sessionId,
|
|
143
|
+
cwd,
|
|
144
|
+
})
|
|
145
|
+
throw new Error(`Guard: ${sandboxRes.reason} — request ${req.id}; present this exact operation in the conversation, then approve via the approve tool after the human consents`)
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if (e instanceof Error && e.message.startsWith('Guard:')) throw e
|
|
148
|
+
throw new Error(`Guard: ${sandboxRes.reason}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// approved: fall through to the shared tail (policy check, secret redaction, next())
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!policy.isAllowed(tool, rawArgs)) {
|
|
155
|
+
throw new Error(`Guard: tool ${tool} denied by policy`)
|
|
156
|
+
}
|
|
157
|
+
if (tool === 'danger-tool' && !(await store.isApproved(tool))) {
|
|
158
|
+
throw new Error(`Guard: tool ${tool} requires approval`)
|
|
159
|
+
}
|
|
160
|
+
if (rawArgs != null) {
|
|
161
|
+
const asText = JSON.stringify(rawArgs)
|
|
162
|
+
if (containsSecret(asText)) {
|
|
163
|
+
const redacted = JSON.parse(redact(asText))
|
|
164
|
+
if ('args' in exec) (exec as any).args = redacted
|
|
165
|
+
if ('arguments' in exec) (exec as any).arguments = redacted
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return next()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default {
|
|
173
|
+
inject: ['tools'] as const,
|
|
174
|
+
apply(ctx: Context) {
|
|
175
|
+
const store = new ApprovalStore()
|
|
176
|
+
const pending = new PendingStore()
|
|
177
|
+
const policy = new PermissionPolicy({ deny: ['danger-tool'] })
|
|
178
|
+
const handler = createGuardHandler(store, policy, pending)
|
|
179
|
+
ctx.effect(() => ctx.on('tools/pre-execute', handler as any))
|
|
180
|
+
// register on-demand full-scan tool (Task 4) alongside guard handler
|
|
181
|
+
applyFullScan(ctx, {})
|
|
182
|
+
applyApproveTools(ctx, { pending })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
function resolveHome(dshHome?: string) { return dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh') }
|
|
7
|
+
export function pendingPath(dshHome?: string) { return join(resolveHome(dshHome), 'dsh-maestro-guard', 'pending.json') }
|
|
8
|
+
|
|
9
|
+
export const MAX_PENDING = 20
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How long an approval (and a pending ticket) stays actionable. Approved grants
|
|
13
|
+
* must not linger indefinitely as standing one-shot passes for any later session,
|
|
14
|
+
* and superseded pending tickets should not clutter the store forever.
|
|
15
|
+
*/
|
|
16
|
+
export const APPROVAL_TTL_MS = 30 * 60 * 1000
|
|
17
|
+
|
|
18
|
+
export interface PendingRequest {
|
|
19
|
+
id: string
|
|
20
|
+
scope: 'git-protection' | 'publish'
|
|
21
|
+
tool: string
|
|
22
|
+
hash: string
|
|
23
|
+
command: string
|
|
24
|
+
reason: string
|
|
25
|
+
sessionId?: string
|
|
26
|
+
cwd?: string
|
|
27
|
+
requestedAt: string
|
|
28
|
+
expiresAt?: string
|
|
29
|
+
status: 'pending' | 'approved' | 'consumed' | 'expired'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let _queue: Promise<unknown> = Promise.resolve()
|
|
33
|
+
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
34
|
+
const p = _queue.then(fn, fn) as Promise<T>
|
|
35
|
+
_queue = p.catch(() => {})
|
|
36
|
+
return p
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ticketHash(scope: string, command: string): string {
|
|
40
|
+
return createHash('sha1').update(scope + '\u0000' + command).digest('hex').slice(0, 12)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isExpired(req: PendingRequest, nowMs: number): boolean {
|
|
44
|
+
return !!req.expiresAt && nowMs > Date.parse(req.expiresAt)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class PendingStore {
|
|
48
|
+
constructor(private dshHome?: string, private now: () => number = Date.now) {}
|
|
49
|
+
private async load(): Promise<{ requests: PendingRequest[] }> {
|
|
50
|
+
try { return JSON.parse(await readFile(pendingPath(this.dshHome), 'utf-8')) } catch { return { requests: [] } }
|
|
51
|
+
}
|
|
52
|
+
private async save(doc: { requests: PendingRequest[] }): Promise<void> {
|
|
53
|
+
const p = pendingPath(this.dshHome)
|
|
54
|
+
await mkdir(dirname(p), { recursive: true, mode: 0o700 })
|
|
55
|
+
await writeFile(p, JSON.stringify(doc, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
56
|
+
await chmod(p, 0o600)
|
|
57
|
+
}
|
|
58
|
+
/** Mark expired pending/approved tickets as 'expired' (mutates, persists). */
|
|
59
|
+
private async prune(doc: { requests: PendingRequest[] }): Promise<void> {
|
|
60
|
+
const t = this.now()
|
|
61
|
+
let changed = false
|
|
62
|
+
for (const r of doc.requests) {
|
|
63
|
+
if ((r.status === 'pending' || r.status === 'approved') && isExpired(r, t)) {
|
|
64
|
+
r.status = 'expired'
|
|
65
|
+
changed = true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (changed) await this.save(doc)
|
|
69
|
+
}
|
|
70
|
+
async record(opts: { scope: 'git-protection' | 'publish'; tool: string; command: string; reason: string; sessionId?: string; cwd?: string }): Promise<PendingRequest> {
|
|
71
|
+
return enqueue(async () => {
|
|
72
|
+
const doc = await this.load()
|
|
73
|
+
await this.prune(doc)
|
|
74
|
+
const hash = ticketHash(opts.scope, opts.command)
|
|
75
|
+
const existing = doc.requests.find(
|
|
76
|
+
(r) => r.scope === opts.scope && r.hash === hash && (r.status === 'pending' || r.status === 'approved'),
|
|
77
|
+
)
|
|
78
|
+
if (existing) return existing
|
|
79
|
+
const req: PendingRequest = {
|
|
80
|
+
id: `g-${randomUUID().slice(0, 8)}`,
|
|
81
|
+
scope: opts.scope,
|
|
82
|
+
tool: opts.tool,
|
|
83
|
+
hash,
|
|
84
|
+
command: opts.command,
|
|
85
|
+
reason: opts.reason,
|
|
86
|
+
sessionId: opts.sessionId,
|
|
87
|
+
cwd: opts.cwd,
|
|
88
|
+
requestedAt: new Date(this.now()).toISOString(),
|
|
89
|
+
expiresAt: new Date(this.now() + APPROVAL_TTL_MS).toISOString(),
|
|
90
|
+
status: 'pending',
|
|
91
|
+
}
|
|
92
|
+
doc.requests.push(req)
|
|
93
|
+
const pending = doc.requests.filter((r) => r.status === 'pending' || r.status === 'approved')
|
|
94
|
+
const resolved = doc.requests.filter((r) => r.status === 'consumed' || r.status === 'expired')
|
|
95
|
+
const drop = Math.max(0, resolved.length - (MAX_PENDING - pending.length))
|
|
96
|
+
await this.save({ requests: [...pending, ...resolved.slice(drop)] })
|
|
97
|
+
return req
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
async list(): Promise<PendingRequest[]> {
|
|
101
|
+
return enqueue(async () => {
|
|
102
|
+
const doc = await this.load()
|
|
103
|
+
await this.prune(doc)
|
|
104
|
+
return [...doc.requests].sort((a, b) => (a.requestedAt < b.requestedAt ? 1 : -1))
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
async approve(id: string): Promise<PendingRequest | undefined> {
|
|
108
|
+
return enqueue(async () => {
|
|
109
|
+
const doc = await this.load()
|
|
110
|
+
await this.prune(doc)
|
|
111
|
+
const req = doc.requests.find((r) => r.id === id)
|
|
112
|
+
if (!req || req.status !== 'pending') return undefined
|
|
113
|
+
req.status = 'approved'
|
|
114
|
+
req.expiresAt = new Date(this.now() + APPROVAL_TTL_MS).toISOString()
|
|
115
|
+
await this.save(doc)
|
|
116
|
+
return req
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
async consume(id: string): Promise<boolean> {
|
|
120
|
+
return enqueue(async () => {
|
|
121
|
+
const doc = await this.load()
|
|
122
|
+
const req = doc.requests.find((r) => r.id === id)
|
|
123
|
+
if (!req || req.status !== 'approved') return false
|
|
124
|
+
req.status = 'consumed'
|
|
125
|
+
await this.save(doc)
|
|
126
|
+
return true
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
async findApprovedByHash(scope: string, hash: string, sessionId?: string): Promise<PendingRequest | undefined> {
|
|
130
|
+
return enqueue(async () => {
|
|
131
|
+
const doc = await this.load()
|
|
132
|
+
await this.prune(doc)
|
|
133
|
+
const t = this.now()
|
|
134
|
+
return doc.requests.find(
|
|
135
|
+
(r) =>
|
|
136
|
+
r.scope === scope &&
|
|
137
|
+
r.hash === hash &&
|
|
138
|
+
r.status === 'approved' &&
|
|
139
|
+
!isExpired(r, t) &&
|
|
140
|
+
(r.sessionId ? r.sessionId === sessionId : true), // legacy tickets without a sessionId stay usable
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join, resolve, normalize } from 'node:path'
|
|
3
|
+
|
|
4
|
+
function expandHome(p: string): string {
|
|
5
|
+
if (!p) return p
|
|
6
|
+
if (p === '~') return homedir()
|
|
7
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2))
|
|
8
|
+
if (p.startsWith('$HOME/')) return join(homedir(), p.slice(6))
|
|
9
|
+
if (p.startsWith('${HOME}/')) return join(homedir(), p.slice(8))
|
|
10
|
+
return p
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizePath(p: string): string {
|
|
14
|
+
try {
|
|
15
|
+
return normalize(expandHome(p))
|
|
16
|
+
} catch {
|
|
17
|
+
return expandHome(p)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True if path points to a blocked credential / secret location.
|
|
23
|
+
* Covers: ~/.dsh/.credentials.yaml (any expansion), ~/.cloudflared, NPM_TOKEN, .credentials.yaml substring
|
|
24
|
+
* credentialPaths: additional custom paths from config (guard.credentialPaths) — merged with always-blocked defaults.
|
|
25
|
+
* The 3 always-blocked substrings stay blocked even when credentialPaths is empty.
|
|
26
|
+
*/
|
|
27
|
+
export function isBlockedPath(input: string, credentialPaths?: string[]): boolean {
|
|
28
|
+
if (!input || typeof input !== 'string') return false
|
|
29
|
+
const trimmed = input.trim()
|
|
30
|
+
if (!trimmed) return false
|
|
31
|
+
|
|
32
|
+
// Direct substring checks (covers JSON-stringified args, env leakage, etc.) — always blocked
|
|
33
|
+
if (trimmed.includes('.credentials.yaml')) return true
|
|
34
|
+
if (trimmed.includes('.cloudflared')) return true
|
|
35
|
+
if (trimmed.includes('NPM_TOKEN')) return true
|
|
36
|
+
if (trimmed.includes('.dsh') && trimmed.includes('credentials')) return true
|
|
37
|
+
|
|
38
|
+
// Normalized expanded check for defaults
|
|
39
|
+
const norm = normalizePath(trimmed)
|
|
40
|
+
if (norm.includes('.credentials.yaml')) return true
|
|
41
|
+
if (norm.includes('.cloudflared')) return true
|
|
42
|
+
// Check absolute homedir variant
|
|
43
|
+
const absCred = join(homedir(), '.dsh', '.credentials.yaml')
|
|
44
|
+
if (norm === absCred || norm.startsWith(absCred)) return true
|
|
45
|
+
const absCf = join(homedir(), '.cloudflared')
|
|
46
|
+
if (norm === absCf || norm.startsWith(absCf + '/') || norm.includes('.cloudflared')) return true
|
|
47
|
+
|
|
48
|
+
// Injected credentialPaths from config (additional) — substring + normalized + prefix
|
|
49
|
+
if (credentialPaths && credentialPaths.length > 0) {
|
|
50
|
+
for (const p of credentialPaths) {
|
|
51
|
+
if (!p || typeof p !== 'string') continue
|
|
52
|
+
const t = p.trim()
|
|
53
|
+
if (!t) continue
|
|
54
|
+
if (trimmed.includes(t)) return true
|
|
55
|
+
const normP = normalizePath(t)
|
|
56
|
+
if (norm.includes(normP)) return true
|
|
57
|
+
if (norm === normP) return true
|
|
58
|
+
if (norm.startsWith(normP + '/')) return true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True if command string is a publish command (pnpm|npm publish)
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the working directory a command actually executes in, when it names
|
|
70
|
+
* one explicitly (cd <dir> / git -C <dir>). Falls back to the passed cwd when
|
|
71
|
+
* the command has no explicit target — preserving the historical session-cwd
|
|
72
|
+
* semantics for commands that run in place.
|
|
73
|
+
*/
|
|
74
|
+
export function getCommandWorkingDir(command: string | undefined, cwd: string | undefined): string | undefined {
|
|
75
|
+
if (!command || !cwd) return cwd ?? undefined
|
|
76
|
+
const hasCdVerb = /\bcd\b/.test(command) || /\bgit\s+-C\b/.test(command)
|
|
77
|
+
const cd = /\bcd\s+([^\s;&|"'`${}]+)(?:\s*(?:[;&|]|$))/.exec(command)
|
|
78
|
+
const c = /\bgit\s+-C\s+([^\s;&|"'`${}]+)/.exec(command)
|
|
79
|
+
const dir = cd?.[1] ?? c?.[1]
|
|
80
|
+
if (!dir) {
|
|
81
|
+
// A cd/-C verb is present but its target cannot be parsed (quoted, $VAR,
|
|
82
|
+
// wildcard, bare `cd`): do NOT assume the session cwd — that reintroduces
|
|
83
|
+
// the false positive when the session cwd repo sits on a protected branch.
|
|
84
|
+
// Unknown target means no protected-branch assumption (segment word checks
|
|
85
|
+
// still apply); commands with no cd verb keep the session-cwd semantics.
|
|
86
|
+
if (hasCdVerb) return undefined
|
|
87
|
+
return cwd
|
|
88
|
+
}
|
|
89
|
+
if (dir === '~') return homedir()
|
|
90
|
+
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2))
|
|
91
|
+
return resolve(cwd, dir)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Choose the branch used for protected-branch detection: the branch of the repo
|
|
96
|
+
* the command targets (via `getCommandWorkingDir`), falling back to the session
|
|
97
|
+
* cwd when the command runs in place. If the command cd's into a directory that
|
|
98
|
+
* is not a git repo, no protected branch applies (the push would fail there
|
|
99
|
+
* anyway) — do NOT fall back to the session cwd, that reintroduces the
|
|
100
|
+
* false-positive where a feature-branch push inside a sub-repo is blocked
|
|
101
|
+
* because the session cwd repo happens to sit on master.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveCurrentBranch(
|
|
104
|
+
command: string | undefined,
|
|
105
|
+
sessionCwd: string | undefined,
|
|
106
|
+
branchOf: (dir: string) => string | undefined,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
const dir = getCommandWorkingDir(command, sessionCwd)
|
|
109
|
+
if (!dir) return undefined
|
|
110
|
+
return branchOf(dir)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The executed command surface of a tool call. Shell-style tools carry their
|
|
115
|
+
* script in `args.command` (bash/exec/shell/govard_shell); bare string args are
|
|
116
|
+
* the command itself. Tools with no command field (read/write/memory/...) have
|
|
117
|
+
* no execution surface, so protected-op detection must not apply to their
|
|
118
|
+
* content — that was the source of the analysis-tool false positives.
|
|
119
|
+
*/
|
|
120
|
+
export function extractCommandText(args: unknown): string | undefined {
|
|
121
|
+
if (args == null) return undefined
|
|
122
|
+
if (typeof args === 'string') return args
|
|
123
|
+
if (typeof args === 'object' && typeof (args as Record<string, unknown>).command === 'string') {
|
|
124
|
+
return (args as Record<string, unknown>).command as string
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Collapse quoted spans — text inside quotes is data (echo/printf/script bodies), not argv. */
|
|
130
|
+
function stripQuoted(cmd: string): string {
|
|
131
|
+
return cmd.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isBlockedCommand(cmd: string): boolean {
|
|
135
|
+
if (!cmd || typeof cmd !== 'string') return false
|
|
136
|
+
// package-manager publish verbs as a whole-word sequence
|
|
137
|
+
return /\b(pnpm|npm)\s+publish\b/.test(stripQuoted(cmd))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* True when the command executes a protected git operation. Detection is
|
|
142
|
+
* per-command-segment (split on && / ; / | / newline) so a `gh pr create
|
|
143
|
+
* --base master` mention after a feature push — or quoted text anywhere — does
|
|
144
|
+
* not turn a safe push into a blocked one. Hard rules (gh pr merge, gh release,
|
|
145
|
+
* protection deletion, protected branch words in the push segment, being
|
|
146
|
+
* checked out on a protected branch) keep their unconditional coverage.
|
|
147
|
+
*/
|
|
148
|
+
export function isBlockedGitCommand(cmd: string, currentBranch?: string, branches?: string[]): boolean {
|
|
149
|
+
if (!cmd || typeof cmd !== 'string') return false
|
|
150
|
+
const effectiveBranches = branches && branches.length > 0 ? branches : ['master', 'main']
|
|
151
|
+
const lowerBranches = effectiveBranches.map((b) => b.toLowerCase())
|
|
152
|
+
const escBranch = (b: string) => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
153
|
+
// Strip quoted spans on the FULL text BEFORE segmenting: a quoted block (an
|
|
154
|
+
// instruction prompt, a multiline -e body) legitimately spans &&/;|/newline
|
|
155
|
+
// separators, and segment-then-strip would fragment it, exposing the words.
|
|
156
|
+
const stripped = stripQuoted(cmd)
|
|
157
|
+
const segments = stripped.split(/\s*(?:&&|\|\||;|\||\r?\n)+\s*/)
|
|
158
|
+
for (const seg of segments) {
|
|
159
|
+
const lower = seg.toLowerCase()
|
|
160
|
+
// hard rules are unconditional per segment
|
|
161
|
+
if (/\bgh\s+pr\s+merge\b/.test(lower)) return true
|
|
162
|
+
if (/\bgh\s+release\s+(create|publish)\b/.test(lower)) return true
|
|
163
|
+
for (const b of lowerBranches) {
|
|
164
|
+
const re = new RegExp(`gh\\s+api\\b.*delete.*\\/branches\\/${escBranch(b)}\\/protection`)
|
|
165
|
+
if (re.test(lower)) return true
|
|
166
|
+
}
|
|
167
|
+
// git push: protected branch word in THIS segment, checked out on one, or
|
|
168
|
+
// pushing a release tag (version tags trigger the CI publish workflow and
|
|
169
|
+
// must follow the same human-approval rule as gh release create)
|
|
170
|
+
if (/\bgit\s+push\b/.test(lower)) {
|
|
171
|
+
for (const b of lowerBranches) {
|
|
172
|
+
if (new RegExp(`\\b${escBranch(b)}\\b`).test(lower)) return true
|
|
173
|
+
}
|
|
174
|
+
if (/refs\/tags\//.test(lower)) return true
|
|
175
|
+
// semver-like tag as a push refspec: standalone token (space/start
|
|
176
|
+
// preceded), so a branch name like feat/1.2.3 is not a false positive
|
|
177
|
+
if (/(?:^|\s)v?\d+\.\d+\.\d+(?:[-+][0-9a-z.]+)?\b/.test(lower)) return true
|
|
178
|
+
if (currentBranch) {
|
|
179
|
+
const curLower = currentBranch.toLowerCase()
|
|
180
|
+
if (lowerBranches.includes(curLower)) return true
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// git tag push that includes protected branch (rare) — already covered by push regex
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function isPublishBlocked(cmd: string, approved: boolean): boolean {
|
|
189
|
+
if (!isBlockedCommand(cmd)) return false
|
|
190
|
+
return !approved
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* True if target path is outside cwd (strict containment).
|
|
195
|
+
* Uses resolve for absolute comparison; expanded ~/ handled.
|
|
196
|
+
*/
|
|
197
|
+
export function isOutsideCwd(target: string, cwd: string): boolean {
|
|
198
|
+
if (!target || !cwd) return false
|
|
199
|
+
const expTarget = expandHome(target)
|
|
200
|
+
const expCwd = expandHome(cwd)
|
|
201
|
+
const resolvedTarget = resolve(expTarget)
|
|
202
|
+
const resolvedCwd = resolve(expCwd)
|
|
203
|
+
if (resolvedTarget === resolvedCwd) return false
|
|
204
|
+
// Ensure cwd prefix with separator to avoid /tmp/proj matching /tmp/proj2
|
|
205
|
+
return !resolvedTarget.startsWith(resolvedCwd + '/')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface SandboxCheckResult {
|
|
209
|
+
blocked: boolean
|
|
210
|
+
reason?: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface CheckSandboxOpts {
|
|
214
|
+
cwd?: string
|
|
215
|
+
currentBranch?: string
|
|
216
|
+
approved?: boolean
|
|
217
|
+
credentialPaths?: string[]
|
|
218
|
+
gitProtection?: { enabled: boolean; branches: string[] }
|
|
219
|
+
publishBlocked?: boolean
|
|
220
|
+
cwdContainment?: boolean
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Central sandbox check. Combines credential-path, git-protection, publish, and cwd containment.
|
|
225
|
+
* @param tool tool name (e.g. maestro_read_file, exec, bash)
|
|
226
|
+
* @param args tool arguments (object, string, or unknown)
|
|
227
|
+
* @param opts.cwd session cwd (exec.agent.session.header.cwd)
|
|
228
|
+
* @param opts.currentBranch git current branch (from getCurrentBranch)
|
|
229
|
+
* @param opts.approved whether publish/git is APPROVED (via ApprovalStore)
|
|
230
|
+
* @param opts.credentialPaths additional blocked credential paths from guard config
|
|
231
|
+
* @param opts.gitProtection git protection toggle + branches from guard config
|
|
232
|
+
* @param opts.publishBlocked whether publish is blocked (default true)
|
|
233
|
+
* @param opts.cwdContainment whether cwd containment is enforced (default true)
|
|
234
|
+
*/
|
|
235
|
+
export function checkSandbox(
|
|
236
|
+
tool: string,
|
|
237
|
+
args: unknown,
|
|
238
|
+
opts?: CheckSandboxOpts,
|
|
239
|
+
): SandboxCheckResult {
|
|
240
|
+
const approved = !!opts?.approved
|
|
241
|
+
const cwd = opts?.cwd
|
|
242
|
+
const currentBranch = opts?.currentBranch
|
|
243
|
+
const credentialPaths = opts?.credentialPaths
|
|
244
|
+
const gitProtection = opts?.gitProtection
|
|
245
|
+
const publishBlocked = opts?.publishBlocked ?? true
|
|
246
|
+
const cwdContainment = opts?.cwdContainment ?? true
|
|
247
|
+
|
|
248
|
+
// Serialize args for generic substring checks
|
|
249
|
+
const asText = args != null ? (typeof args === 'string' ? args : JSON.stringify(args)) : ''
|
|
250
|
+
const combined = `${tool ?? ''} ${asText}`
|
|
251
|
+
// Protected-op detection (git/publish) runs on the executed command surface
|
|
252
|
+
// only — non-shell tools (write/read/memory/...) have no command and must not
|
|
253
|
+
// be flagged for text that merely mentions a protected phrase.
|
|
254
|
+
const execText = extractCommandText(args)
|
|
255
|
+
|
|
256
|
+
// 1) Block credential paths anywhere in tool+args (always, with injected list)
|
|
257
|
+
if (isBlockedPath(tool, credentialPaths) || isBlockedPath(asText, credentialPaths) || isBlockedPath(combined, credentialPaths)) {
|
|
258
|
+
return { blocked: true, reason: 'credential path blocked: ~/.dsh/.credentials.yaml or ~/.cloudflared or NPM_TOKEN' }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2) Block git push to protected branches without APPROVED (branch-aware, toggle-aware)
|
|
262
|
+
const gitEnabled = gitProtection?.enabled ?? true
|
|
263
|
+
const branches = gitProtection?.branches ?? ['master', 'main']
|
|
264
|
+
if (gitEnabled && execText && isBlockedGitCommand(execText, currentBranch, branches) && !approved) {
|
|
265
|
+
return { blocked: true, reason: 'git push to master/main blocked without APPROVED: ' + execText.slice(0, 300) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3) Block publish without APPROVED (toggle-aware)
|
|
269
|
+
if (publishBlocked && execText && isBlockedCommand(execText) && !approved) {
|
|
270
|
+
return { blocked: true, reason: 'publish blocked without APPROVED: pnpm publish requires approval' }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 4) Block maestro file tools outside cwd (toggle-aware)
|
|
274
|
+
const fileTools = new Set(['maestro_read_file', 'maestro_write_file', 'fs_read', 'fs_write', 'read_file', 'write_file'])
|
|
275
|
+
if (cwdContainment && cwd && fileTools.has(tool)) {
|
|
276
|
+
let pathVal: string | undefined
|
|
277
|
+
if (typeof args === 'object' && args !== null) {
|
|
278
|
+
const a = args as Record<string, unknown>
|
|
279
|
+
// common keys
|
|
280
|
+
pathVal = (a.path as string) ?? (a.file as string) ?? (a.file_path as string) ?? (a.filePath as string)
|
|
281
|
+
// array-like args: {0: 'path'}
|
|
282
|
+
if (!pathVal && typeof (a as any)[0] === 'string') pathVal = (a as any)[0] as string
|
|
283
|
+
// if args itself has nested command with path
|
|
284
|
+
if (!pathVal && typeof a.command === 'string') {
|
|
285
|
+
// command may contain path; fallback to blocked path check already done, but also cwd check if command contains path
|
|
286
|
+
// no explicit path to check
|
|
287
|
+
}
|
|
288
|
+
} else if (typeof args === 'string') {
|
|
289
|
+
pathVal = args
|
|
290
|
+
}
|
|
291
|
+
if (pathVal) {
|
|
292
|
+
if (isBlockedPath(pathVal, credentialPaths)) {
|
|
293
|
+
return { blocked: true, reason: `credential path blocked: ${pathVal}` }
|
|
294
|
+
}
|
|
295
|
+
if (isOutsideCwd(pathVal, cwd)) {
|
|
296
|
+
return { blocked: true, reason: `path outside cwd: ${pathVal} not in ${cwd}` }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Also check generic exec/bash tool with cwd containment if it looks like a file path arg
|
|
302
|
+
// (optional: not strictly required for credential/publish but supports broader sandbox)
|
|
303
|
+
if (cwd && (tool === 'exec' || tool === 'bash' || tool === 'shell')) {
|
|
304
|
+
// already covered publish; for credential path we already blocked above via substring
|
|
305
|
+
// No additional cwd check for shell commands unless they are obvious file paths
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { blocked: false }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Simple guard per task 5 snippet: guard(p) => false if blocked, true if allowed.
|
|
313
|
+
* For publish, second arg approved indicates APPROVED.
|
|
314
|
+
*/
|
|
315
|
+
export function guard(p: string, approved?: boolean): boolean {
|
|
316
|
+
if (!p || typeof p !== 'string') return true
|
|
317
|
+
if (isBlockedPath(p)) return false
|
|
318
|
+
if (isBlockedCommand(p) && !approved) return false
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export const sandbox = { isBlockedPath, isBlockedCommand, isBlockedGitCommand, isPublishBlocked, isOutsideCwd, checkSandbox, guard }
|