@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.
Files changed (39) hide show
  1. package/cordis.patch.yml +1 -0
  2. package/lib/approval-store.d.ts.map +1 -1
  3. package/lib/approval-store.js.map +1 -1
  4. package/lib/approve-tool.d.ts +25 -0
  5. package/lib/approve-tool.d.ts.map +1 -0
  6. package/lib/approve-tool.js +54 -0
  7. package/lib/approve-tool.js.map +1 -0
  8. package/lib/full-scan-tool.d.ts +7 -0
  9. package/lib/full-scan-tool.d.ts.map +1 -0
  10. package/lib/full-scan-tool.js +79 -0
  11. package/lib/full-scan-tool.js.map +1 -0
  12. package/lib/index.d.ts +2 -1
  13. package/lib/index.d.ts.map +1 -1
  14. package/lib/index.js +144 -2
  15. package/lib/index.js.map +1 -1
  16. package/lib/pending.d.ts +44 -0
  17. package/lib/pending.d.ts.map +1 -0
  18. package/lib/pending.js +133 -0
  19. package/lib/pending.js.map +1 -0
  20. package/lib/permission-policy.d.ts.map +1 -1
  21. package/lib/permission-policy.js.map +1 -1
  22. package/lib/sandbox.d.ts +95 -0
  23. package/lib/sandbox.d.ts.map +1 -0
  24. package/lib/sandbox.js +321 -0
  25. package/lib/sandbox.js.map +1 -0
  26. package/lib/secret-redactor.d.ts.map +1 -1
  27. package/lib/secret-redactor.js.map +1 -1
  28. package/package.json +6 -1
  29. package/src/host/approve-tool.ts +61 -0
  30. package/src/host/full-scan-tool.ts +71 -0
  31. package/src/host/index.ts +184 -0
  32. package/src/host/pending.ts +144 -0
  33. package/src/host/sandbox.ts +322 -0
  34. package/src/sandbox.ts +5 -0
  35. package/src/index.ts +0 -38
  36. /package/src/{approval-store.ts → host/approval-store.ts} +0 -0
  37. /package/src/{augment.d.ts → host/augment.d.ts} +0 -0
  38. /package/src/{permission-policy.ts → host/permission-policy.ts} +0 -0
  39. /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 }
package/src/sandbox.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Re-export for Task 5 compatibility: `import { guard } from '../src/sandbox.js'`
3
+ * Real implementation lives in src/host/sandbox.ts (host-only, Cordis).
4
+ */
5
+ export * from './host/sandbox.js';