pushary 1.8.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/CHANGELOG.md +1943 -0
- package/LICENSE +21 -0
- package/README.md +330 -0
- package/data/SKILL.md +591 -0
- package/data/cowork/SKILL.md +77 -0
- package/data/cursor-plugin/.cursor-plugin/plugin.json +25 -0
- package/data/cursor-plugin/CHANGELOG.md +70 -0
- package/data/cursor-plugin/CONTRIBUTING.md +40 -0
- package/data/cursor-plugin/LICENSE +21 -0
- package/data/cursor-plugin/README.md +113 -0
- package/data/cursor-plugin/SECURITY.md +33 -0
- package/data/cursor-plugin/assets/logo.png +0 -0
- package/data/cursor-plugin/commands/notify-when-done.md +14 -0
- package/data/cursor-plugin/commands/pushary-test.md +13 -0
- package/data/cursor-plugin/hooks/hooks.json +75 -0
- package/data/cursor-plugin/mcp.json +11 -0
- package/data/cursor-plugin/rules/pushary.mdc +40 -0
- package/data/cursor-plugin/scripts/pushary-gate.mjs +724 -0
- package/data/cursor-plugin/scripts/pushary-gate.test.mjs +137 -0
- package/data/cursor-plugin/scripts/redaction.mjs +51 -0
- package/data/cursor-plugin/skills/pushary/SKILL.md +584 -0
- package/data/vscode-plugin/.claude-plugin/plugin.json +28 -0
- package/data/vscode-plugin/.mcp.json +11 -0
- package/data/vscode-plugin/CHANGELOG.md +49 -0
- package/data/vscode-plugin/CONTRIBUTING.md +40 -0
- package/data/vscode-plugin/LICENSE +21 -0
- package/data/vscode-plugin/README.md +128 -0
- package/data/vscode-plugin/SECURITY.md +56 -0
- package/data/vscode-plugin/assets/logo.png +0 -0
- package/data/vscode-plugin/commands/notify-when-done.md +14 -0
- package/data/vscode-plugin/commands/pushary-test.md +13 -0
- package/data/vscode-plugin/hooks/hooks.json +60 -0
- package/data/vscode-plugin/scripts/pushary-gate.mjs +762 -0
- package/data/vscode-plugin/scripts/redaction.mjs +51 -0
- package/data/vscode-plugin/skills/pushary/SKILL.md +584 -0
- package/dist/bin/pushary-bell-hook.d.ts +1 -0
- package/dist/bin/pushary-bell-hook.js +46 -0
- package/dist/bin/pushary-bell.d.ts +1 -0
- package/dist/bin/pushary-bell.js +146 -0
- package/dist/bin/pushary-claude.d.ts +1 -0
- package/dist/bin/pushary-claude.js +1916 -0
- package/dist/bin/pushary-clean.d.ts +1 -0
- package/dist/bin/pushary-clean.js +1300 -0
- package/dist/bin/pushary-codex-bridge.d.ts +1 -0
- package/dist/bin/pushary-codex-bridge.js +599 -0
- package/dist/bin/pushary-codex-hook.d.ts +1 -0
- package/dist/bin/pushary-codex-hook.js +886 -0
- package/dist/bin/pushary-codex.d.ts +1 -0
- package/dist/bin/pushary-codex.js +125 -0
- package/dist/bin/pushary-connect.d.ts +1 -0
- package/dist/bin/pushary-connect.js +100 -0
- package/dist/bin/pushary-cowork.d.ts +1 -0
- package/dist/bin/pushary-cowork.js +36 -0
- package/dist/bin/pushary-daemon-supervisor.d.ts +1 -0
- package/dist/bin/pushary-daemon-supervisor.js +27 -0
- package/dist/bin/pushary-daemon.d.ts +1 -0
- package/dist/bin/pushary-daemon.js +878 -0
- package/dist/bin/pushary-disconnect.d.ts +1 -0
- package/dist/bin/pushary-disconnect.js +264 -0
- package/dist/bin/pushary-doctor.d.ts +1 -0
- package/dist/bin/pushary-doctor.js +1433 -0
- package/dist/bin/pushary-elicitation-hook.d.ts +1 -0
- package/dist/bin/pushary-elicitation-hook.js +149 -0
- package/dist/bin/pushary-gemini-bridge.d.ts +1 -0
- package/dist/bin/pushary-gemini-bridge.js +339 -0
- package/dist/bin/pushary-gemini-hook.d.ts +1 -0
- package/dist/bin/pushary-gemini-hook.js +381 -0
- package/dist/bin/pushary-hook.d.ts +1 -0
- package/dist/bin/pushary-hook.js +95 -0
- package/dist/bin/pushary-login.d.ts +1 -0
- package/dist/bin/pushary-login.js +198 -0
- package/dist/bin/pushary-logout.d.ts +1 -0
- package/dist/bin/pushary-logout.js +147 -0
- package/dist/bin/pushary-mcp.d.ts +1 -0
- package/dist/bin/pushary-mcp.js +114 -0
- package/dist/bin/pushary-mode.d.ts +1 -0
- package/dist/bin/pushary-mode.js +130 -0
- package/dist/bin/pushary-notification-hook.d.ts +1 -0
- package/dist/bin/pushary-notification-hook.js +56 -0
- package/dist/bin/pushary-opencode-hook.d.ts +1 -0
- package/dist/bin/pushary-opencode-hook.js +338 -0
- package/dist/bin/pushary-permission-denied-hook.d.ts +1 -0
- package/dist/bin/pushary-permission-denied-hook.js +60 -0
- package/dist/bin/pushary-permission-hook.d.ts +1 -0
- package/dist/bin/pushary-permission-hook.js +65 -0
- package/dist/bin/pushary-post-hook.d.ts +1 -0
- package/dist/bin/pushary-post-hook.js +70 -0
- package/dist/bin/pushary-prompt-hook.d.ts +1 -0
- package/dist/bin/pushary-prompt-hook.js +61 -0
- package/dist/bin/pushary-session-end-hook.d.ts +1 -0
- package/dist/bin/pushary-session-end-hook.js +59 -0
- package/dist/bin/pushary-session-start-hook.d.ts +1 -0
- package/dist/bin/pushary-session-start-hook.js +72 -0
- package/dist/bin/pushary-setup.d.ts +1 -0
- package/dist/bin/pushary-setup.js +2660 -0
- package/dist/bin/pushary-stats.d.ts +1 -0
- package/dist/bin/pushary-stats.js +44 -0
- package/dist/bin/pushary-status.d.ts +1 -0
- package/dist/bin/pushary-status.js +262 -0
- package/dist/bin/pushary-stop-hook.d.ts +1 -0
- package/dist/bin/pushary-stop-hook.js +63 -0
- package/dist/bin/pushary-stopfailure-hook.d.ts +1 -0
- package/dist/bin/pushary-stopfailure-hook.js +56 -0
- package/dist/bin/pushary-suggestions.d.ts +1 -0
- package/dist/bin/pushary-suggestions.js +100 -0
- package/dist/bin/pushary-transcript-register.d.ts +1 -0
- package/dist/bin/pushary-transcript-register.js +40 -0
- package/dist/bin/pushary-transcripts.d.ts +1 -0
- package/dist/bin/pushary-transcripts.js +77 -0
- package/dist/bin/pushary-upgrade.d.ts +1 -0
- package/dist/bin/pushary-upgrade.js +599 -0
- package/dist/bin/pushary-wait.d.ts +1 -0
- package/dist/bin/pushary-wait.js +122 -0
- package/dist/bin/pushary.d.ts +1 -0
- package/dist/bin/pushary.js +74 -0
- package/dist/chunk-2ABTSGFT.js +173 -0
- package/dist/chunk-2OY3ZZ5N.js +124 -0
- package/dist/chunk-3EGEA4KH.js +44 -0
- package/dist/chunk-3EVPNIBE.js +185 -0
- package/dist/chunk-5OP5MQI7.js +45 -0
- package/dist/chunk-6J2JC2RT.js +149 -0
- package/dist/chunk-7QLSKOSU.js +19 -0
- package/dist/chunk-7Z7Q4GGS.js +88 -0
- package/dist/chunk-A2A2YTMG.js +835 -0
- package/dist/chunk-AHV4LNB5.js +377 -0
- package/dist/chunk-AXPCESYO.js +197 -0
- package/dist/chunk-C2WCPBF7.js +86 -0
- package/dist/chunk-CJVVKOSR.js +73 -0
- package/dist/chunk-CULUZTWQ.js +2117 -0
- package/dist/chunk-CVQYGMSR.js +142 -0
- package/dist/chunk-CVWG2N3Y.js +16 -0
- package/dist/chunk-D2FX3EPW.js +15 -0
- package/dist/chunk-DB3ONZB4.js +44 -0
- package/dist/chunk-DE4Y6TCC.js +160 -0
- package/dist/chunk-E4FJTUH4.js +249 -0
- package/dist/chunk-EPCXUP3P.js +41 -0
- package/dist/chunk-EX3GAPA4.js +44 -0
- package/dist/chunk-EYK3GSRH.js +143 -0
- package/dist/chunk-GMXKITVA.js +70 -0
- package/dist/chunk-GPEGOGRJ.js +49 -0
- package/dist/chunk-GU3EJCVV.js +43 -0
- package/dist/chunk-HQ6G3B5R.js +59 -0
- package/dist/chunk-HZVVNGEW.js +22 -0
- package/dist/chunk-IPVKH2ST.js +231 -0
- package/dist/chunk-J5TIV3J6.js +238 -0
- package/dist/chunk-JU6W3XAU.js +64 -0
- package/dist/chunk-JYG4B33G.js +227 -0
- package/dist/chunk-JZTHGAPA.js +503 -0
- package/dist/chunk-KGDWDSNL.js +102 -0
- package/dist/chunk-KVQBWI5T.js +99 -0
- package/dist/chunk-KZERVKTD.js +16 -0
- package/dist/chunk-LXQ5FBBD.js +47 -0
- package/dist/chunk-M5ICYGHT.js +138 -0
- package/dist/chunk-M5JDZ45W.js +943 -0
- package/dist/chunk-MDEPU45E.js +1226 -0
- package/dist/chunk-MPWXGONV.js +157 -0
- package/dist/chunk-N6IOSZOE.js +185 -0
- package/dist/chunk-NGDD6TXY.js +192 -0
- package/dist/chunk-NQTHFF4W.js +2830 -0
- package/dist/chunk-OKXS7WDZ.js +68 -0
- package/dist/chunk-OLQRRFBX.js +21 -0
- package/dist/chunk-P7WQEVWA.js +307 -0
- package/dist/chunk-POFBE6ZY.js +12 -0
- package/dist/chunk-PQ4K74WL.js +30 -0
- package/dist/chunk-PUZZELE6.js +37 -0
- package/dist/chunk-Q7E4I6UA.js +767 -0
- package/dist/chunk-QCGNGEGE.js +15 -0
- package/dist/chunk-R7437YI3.js +151 -0
- package/dist/chunk-RHR6NARM.js +305 -0
- package/dist/chunk-SAXBC4AZ.js +60 -0
- package/dist/chunk-SGZJ5LYH.js +76 -0
- package/dist/chunk-SIFVFP6T.js +70 -0
- package/dist/chunk-SLFABHW3.js +150 -0
- package/dist/chunk-T7E75EKA.js +67 -0
- package/dist/chunk-TY4JT7W3.js +38 -0
- package/dist/chunk-U4TMAANR.js +111 -0
- package/dist/chunk-UI36QSBI.js +177 -0
- package/dist/chunk-VQKRG3AC.js +339 -0
- package/dist/chunk-WE2J62NC.js +1131 -0
- package/dist/chunk-XHKBHWLX.js +14 -0
- package/dist/chunk-ZSZSSJCV.js +275 -0
- package/dist/chunk-ZVSKMPBZ.js +135 -0
- package/dist/reapply-CW3MA66V.js +34 -0
- package/dist/src/index.d.ts +204 -0
- package/dist/src/index.js +64 -0
- package/package.json +108 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pushary VS Code hooks: server policy, fenced phone approvals, and lifecycle telemetry.
|
|
3
|
+
// Dependency-free for marketplace installs. Regenerate agent-hooks/data after edits.
|
|
4
|
+
|
|
5
|
+
import { redactSecrets, redactSecretsDeep } from './redaction.mjs'
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
7
|
+
import { homedir, hostname } from 'node:os'
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { basename, dirname, isAbsolute, join, posix, resolve } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || process.env.PUSHARY_API_URL?.trim() || 'https://pushary.com'
|
|
13
|
+
const MCP_URL = `${BASE_URL}/api/mcp/mcp`
|
|
14
|
+
const MAX_BLOCK_MS = 45_000 // longest we can wait inside VS Code's hook timeout
|
|
15
|
+
const WAIT_CHUNK_MS = 20_000 // per wait_for_answer long-poll
|
|
16
|
+
const POLL_GAP_MS = 1_500 // pause between polls after a transient error
|
|
17
|
+
const NET_TIMEOUT_MS = 27_000 // abort a single MCP request
|
|
18
|
+
const WITHDRAW_TIMEOUT_MS = 4_000
|
|
19
|
+
const HARD_GUARD_MS = 55_000 // force a graceful "ask" before the 60s hook timeout
|
|
20
|
+
|
|
21
|
+
// Which commands are worth a phone approval. This is the only place the set is
|
|
22
|
+
// defined; edit it here and nowhere else.
|
|
23
|
+
// VS Code, Copilot CLI and Claude Code each name the terminal tool differently,
|
|
24
|
+
// and the name has changed across VS Code releases. Matching a normalized form
|
|
25
|
+
// of every spelling we have seen is what keeps the gate working after an upgrade
|
|
26
|
+
// renames the tool; an unknown name falls through to the fast pass-through,
|
|
27
|
+
// which is the safe direction (VS Code still shows its own prompt).
|
|
28
|
+
const TERMINAL_TOOLS = new Set([
|
|
29
|
+
'runterminalcommand',
|
|
30
|
+
'runinterminal',
|
|
31
|
+
'runcommand',
|
|
32
|
+
'terminalcommand',
|
|
33
|
+
'executecommand',
|
|
34
|
+
'runinterminalcommand',
|
|
35
|
+
'bash',
|
|
36
|
+
'shell',
|
|
37
|
+
'terminal',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const normalizeToolName = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
41
|
+
|
|
42
|
+
const READ_ONLY_TOOLS = new Set([
|
|
43
|
+
'readfile', 'viewimage', 'listdir', 'listdirectory', 'filesearch', 'findfiles', 'grepsearch', 'findtextinfiles',
|
|
44
|
+
'semanticsearch', 'searchcodebase', 'searchworkspacesymbols', 'geterrors', 'getchangedfiles', 'readprojectstructure',
|
|
45
|
+
'getnotebooksummary', 'readnotebookcelloutput', 'testsearch', 'findtestfiles', 'testfailure', 'getvscodeapi',
|
|
46
|
+
'getterminaloutput', 'gettaskoutput', 'terminalselection', 'terminallastcommand',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
export const isReadOnlyTool = (toolName) =>
|
|
50
|
+
typeof toolName === 'string' && READ_ONLY_TOOLS.has(normalizeToolName(toolName.replace(/^copilot_/, '')))
|
|
51
|
+
|
|
52
|
+
// ── VS Code decisions ─────────────────────────────────────────────────────────
|
|
53
|
+
const PASS = { continue: true }
|
|
54
|
+
const decision = (permissionDecision, permissionDecisionReason) => ({
|
|
55
|
+
hookSpecificOutput: {
|
|
56
|
+
hookEventName: 'PreToolUse',
|
|
57
|
+
permissionDecision,
|
|
58
|
+
...(permissionDecisionReason ? { permissionDecisionReason } : {}),
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
const ALLOW = decision('allow')
|
|
62
|
+
const ask = (reason) => decision('ask', reason)
|
|
63
|
+
const deny = (reason) => decision('deny', reason)
|
|
64
|
+
const unresolved = (toolName, reason) => (isReadOnlyTool(toolName) ? PASS : ask(reason))
|
|
65
|
+
|
|
66
|
+
let activeQuestion
|
|
67
|
+
let done = false
|
|
68
|
+
const respond = (result) => {
|
|
69
|
+
if (done) return
|
|
70
|
+
done = true
|
|
71
|
+
process.stdout.write(JSON.stringify(result))
|
|
72
|
+
process.exit(0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Always surfaced in VS Code's chat hooks output channel, so a silent
|
|
76
|
+
// fall-through to "ask" is explainable instead of a mystery.
|
|
77
|
+
const diag = (message) => {
|
|
78
|
+
try {
|
|
79
|
+
process.stderr.write(`[pushary-gate] ${message}\n`)
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
84
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi)
|
|
85
|
+
|
|
86
|
+
const withRetry = async (fn, attempts) => {
|
|
87
|
+
let lastError
|
|
88
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
89
|
+
try {
|
|
90
|
+
return await fn()
|
|
91
|
+
} catch (error) {
|
|
92
|
+
lastError = error
|
|
93
|
+
if (i < attempts - 1) await sleep(300 * (i + 1))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw lastError
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A synchronous read of fd 0 survives launcher cases where the async stream
|
|
100
|
+
// yields nothing (seen with GUI-spawned hooks on Windows), so try it first and
|
|
101
|
+
// only stream as a fallback.
|
|
102
|
+
const readStdin = async () => {
|
|
103
|
+
try {
|
|
104
|
+
const sync = readFileSync(0, 'utf-8')
|
|
105
|
+
if (sync && sync.trim()) return sync
|
|
106
|
+
} catch {}
|
|
107
|
+
try {
|
|
108
|
+
let raw = ''
|
|
109
|
+
process.stdin.setEncoding('utf-8')
|
|
110
|
+
for await (const chunk of process.stdin) raw += chunk
|
|
111
|
+
return raw
|
|
112
|
+
} catch {
|
|
113
|
+
return ''
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const getMachineId = () => createHash('sha256').update(hostname()).digest('hex').slice(0, 8)
|
|
118
|
+
|
|
119
|
+
// Env first. VS Code launched from Finder/Dock does not inherit a shell profile,
|
|
120
|
+
// so PUSHARY_API_KEY is frequently absent even when the user set it correctly.
|
|
121
|
+
// Fall back to the key the CLI installer embeds in the sibling .mcp.json, then
|
|
122
|
+
// to the key `pushary setup` writes to ~/.pushary/config.json.
|
|
123
|
+
// Deliberately looser than API_KEY_PATTERN in @pushary/contracts (which is hex
|
|
124
|
+
// only). This gate cannot import the workspace, so a strict copy here would
|
|
125
|
+
// silently stop finding the key the day the key alphabet widens, and the symptom
|
|
126
|
+
// would be "approvals stopped working" with no error. Matches the Cursor gate.
|
|
127
|
+
const KEY_SHAPE = /^pk_[a-z0-9]+\.[a-z0-9]+$/i
|
|
128
|
+
|
|
129
|
+
const resolveApiKey = () => {
|
|
130
|
+
const fromEnv = process.env.PUSHARY_API_KEY?.trim()
|
|
131
|
+
if (fromEnv) return fromEnv
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const mcpPath = join(dirname(fileURLToPath(import.meta.url)), '..', '.mcp.json')
|
|
135
|
+
const auth = JSON.parse(readFileSync(mcpPath, 'utf-8'))?.mcpServers?.pushary?.headers?.Authorization ?? ''
|
|
136
|
+
const key = auth.replace(/^Bearer\s+/i, '').trim()
|
|
137
|
+
if (KEY_SHAPE.test(key)) return key
|
|
138
|
+
} catch {}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const configPath = process.env.PUSHARY_CONFIG_FILE?.trim() || join(homedir(), '.pushary', 'config.json')
|
|
142
|
+
const key = JSON.parse(readFileSync(configPath, 'utf-8'))?.apiKey
|
|
143
|
+
if (typeof key === 'string' && key.trim()) return key.trim()
|
|
144
|
+
} catch {}
|
|
145
|
+
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── MCP transport (JSON or SSE) ───────────────────────────────────────────────
|
|
150
|
+
const parseMcpBody = (body, contentType) => {
|
|
151
|
+
if (contentType && contentType.includes('text/event-stream')) {
|
|
152
|
+
let last = null
|
|
153
|
+
for (const frame of body.split(/\r?\n\r?\n/)) {
|
|
154
|
+
const data = frame
|
|
155
|
+
.split(/\r?\n/)
|
|
156
|
+
.filter((line) => line.startsWith('data:'))
|
|
157
|
+
.map((line) => line.slice(5).trimStart())
|
|
158
|
+
.join('\n')
|
|
159
|
+
.trim()
|
|
160
|
+
if (!data) continue
|
|
161
|
+
try {
|
|
162
|
+
last = JSON.parse(data)
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
if (!last) throw new Error('empty SSE response')
|
|
166
|
+
return last
|
|
167
|
+
}
|
|
168
|
+
return JSON.parse(body)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const callTool = async (apiKey, name, args, timeoutMs = NET_TIMEOUT_MS) => {
|
|
172
|
+
const response = await fetch(MCP_URL, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
Accept: 'application/json, text/event-stream',
|
|
177
|
+
Authorization: `Bearer ${apiKey}`,
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method: 'tools/call', params: { name, arguments: args } }),
|
|
180
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
181
|
+
})
|
|
182
|
+
const text = await response.text()
|
|
183
|
+
if (!response.ok) throw new Error(`Pushary MCP ${response.status}`)
|
|
184
|
+
const rpc = parseMcpBody(text, response.headers.get('content-type'))
|
|
185
|
+
if (rpc.error) throw new Error(rpc.error.message || 'Pushary MCP error')
|
|
186
|
+
const payload = rpc.result?.content?.[0]?.text
|
|
187
|
+
if (!payload) throw new Error('empty Pushary response')
|
|
188
|
+
return JSON.parse(payload)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── The verdict ─────────────────────────────────────────────────────────────
|
|
192
|
+
// Asked for, not worked out here.
|
|
193
|
+
//
|
|
194
|
+
// The copy this replaces matched on tool NAME alone: it looked for a rule whose
|
|
195
|
+
// `tool` equalled "Bash", so every argument-scoped rule a user had written --
|
|
196
|
+
// `Bash(rm:*)`, `Bash(git push)` -- was skipped entirely and the broad rule or
|
|
197
|
+
// the default applied instead. A deny written to stop `rm -rf` did nothing here
|
|
198
|
+
// while it worked in Claude Code and Cursor, and it never gained
|
|
199
|
+
// `isSafeReadOnlyCommand` either, so `git status` reached the phone.
|
|
200
|
+
//
|
|
201
|
+
// Its mode fetch also read an unreachable server as `kill: false`, which is not
|
|
202
|
+
// "you are not halted" but "we could not ask". A remote stop nobody could fetch
|
|
203
|
+
// looked like a clean bill of health.
|
|
204
|
+
//
|
|
205
|
+
// The server now answers with the same `resolveGate` the CLI runs, over the same
|
|
206
|
+
// state, and it distinguishes state it could not establish from state that says
|
|
207
|
+
// no. This asks it.
|
|
208
|
+
|
|
209
|
+
const GATE_TIMEOUT_MS = 5000
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The verdict for one command, or null if we could not get one.
|
|
213
|
+
*
|
|
214
|
+
* Null is not a denial and not an approval: every failure hands the call back to
|
|
215
|
+
* VS Code's own prompt, exactly as if this gate were not installed.
|
|
216
|
+
*
|
|
217
|
+
* Uses the VS Code policy profile and the same repository identity carried
|
|
218
|
+
* by the later question. Tool aliases and targets are canonicalized server-side.
|
|
219
|
+
*/
|
|
220
|
+
const decide = async (apiKey, command, cwd, sessionId, ident = {}) => {
|
|
221
|
+
try {
|
|
222
|
+
const response = await fetch(`${BASE_URL}/api/agent/gate`, {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
225
|
+
body: JSON.stringify({
|
|
226
|
+
v: 1,
|
|
227
|
+
source: 'vscode',
|
|
228
|
+
toolName: ident.toolName ?? 'Bash',
|
|
229
|
+
toolInputs: ident.toolInputs ?? [{ command }],
|
|
230
|
+
repoKey: deriveRepoKey(cwd),
|
|
231
|
+
cwd,
|
|
232
|
+
sessionId,
|
|
233
|
+
}),
|
|
234
|
+
signal: AbortSignal.timeout(GATE_TIMEOUT_MS),
|
|
235
|
+
})
|
|
236
|
+
if (!response.ok) return null
|
|
237
|
+
const verdict = await response.json()
|
|
238
|
+
return verdict && typeof verdict.kind === 'string' ? verdict : null
|
|
239
|
+
} catch {
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const REPO_KEY_MAX_LENGTH = 200
|
|
245
|
+
const MAX_PARENT_WALK = 64
|
|
246
|
+
|
|
247
|
+
const normalizeRepoRemote = (remoteUrl) => {
|
|
248
|
+
const raw = (remoteUrl || '').trim()
|
|
249
|
+
if (!raw) return undefined
|
|
250
|
+
let hostAndPath
|
|
251
|
+
const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):(.+)$/.exec(raw)
|
|
252
|
+
if (scp) {
|
|
253
|
+
hostAndPath = `${scp[1]}/${scp[2]}`
|
|
254
|
+
} else {
|
|
255
|
+
const url = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(raw)
|
|
256
|
+
if (!url || /^file:/i.test(raw)) return undefined
|
|
257
|
+
hostAndPath = url[1]
|
|
258
|
+
}
|
|
259
|
+
const slash = hostAndPath.indexOf('/')
|
|
260
|
+
if (slash <= 0) return undefined
|
|
261
|
+
const at = hostAndPath.slice(0, slash).lastIndexOf('@')
|
|
262
|
+
// Credentials must never survive: a remote can carry a token and this value is
|
|
263
|
+
// sent to the server and persisted.
|
|
264
|
+
const host = (at === -1 ? hostAndPath.slice(0, slash) : hostAndPath.slice(at + 1, slash)).replace(/:\d+$/, '')
|
|
265
|
+
const path = hostAndPath.slice(slash + 1).split('/').filter(Boolean).join('/')
|
|
266
|
+
if (!host || !path) return undefined
|
|
267
|
+
return `${host}/${path}`.replace(/\.git$/i, '').toLowerCase().slice(0, REPO_KEY_MAX_LENGTH)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const findGitDir = (startDir) => {
|
|
271
|
+
let current = startDir
|
|
272
|
+
for (let depth = 0; depth < MAX_PARENT_WALK; depth += 1) {
|
|
273
|
+
const candidate = join(current, '.git')
|
|
274
|
+
try {
|
|
275
|
+
if (existsSync(candidate)) {
|
|
276
|
+
const pointer = readFileSync(candidate, 'utf-8').trim()
|
|
277
|
+
// A directory read throws EISDIR, which is the ordinary-clone case.
|
|
278
|
+
if (pointer.startsWith('gitdir:')) {
|
|
279
|
+
const target = pointer.slice('gitdir:'.length).trim()
|
|
280
|
+
return target.startsWith('/') ? target : join(current, target)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error?.code === 'EISDIR') return candidate
|
|
285
|
+
}
|
|
286
|
+
const parent = dirname(current)
|
|
287
|
+
if (parent === current) return undefined
|
|
288
|
+
current = parent
|
|
289
|
+
}
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const deriveRepoKey = (cwd) => {
|
|
294
|
+
const override = process.env.PUSHARY_REPO_KEY
|
|
295
|
+
if (typeof override === 'string') {
|
|
296
|
+
const trimmed = override.trim()
|
|
297
|
+
if (trimmed.toLowerCase() === 'off') return undefined
|
|
298
|
+
if (trimmed) return trimmed.toLowerCase()
|
|
299
|
+
}
|
|
300
|
+
const dir = cwd || process.cwd()
|
|
301
|
+
try {
|
|
302
|
+
const gitDir = findGitDir(dir)
|
|
303
|
+
if (gitDir) {
|
|
304
|
+
// A worktree's config lives in the main git directory.
|
|
305
|
+
const wt = gitDir.replace(/\\/g, '/').indexOf('/worktrees/')
|
|
306
|
+
const configPath = wt === -1 ? join(gitDir, 'config') : join(gitDir.slice(0, wt), 'config')
|
|
307
|
+
if (existsSync(configPath)) {
|
|
308
|
+
let inOrigin = false
|
|
309
|
+
for (const rawLine of readFileSync(configPath, 'utf-8').split('\n')) {
|
|
310
|
+
const line = rawLine.trim()
|
|
311
|
+
if (line.startsWith('[')) { inOrigin = /^\[remote "origin"\]/.test(line); continue }
|
|
312
|
+
if (!inOrigin) continue
|
|
313
|
+
const url = /^url\s*=\s*(.+)$/.exec(line)
|
|
314
|
+
if (url) {
|
|
315
|
+
const normalized = normalizeRepoRemote(url[1].trim())
|
|
316
|
+
if (normalized) return normalized
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const root = gitDir.endsWith('.git') ? dirname(gitDir) : dir
|
|
321
|
+
return `local/${basename(root).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
|
|
322
|
+
}
|
|
323
|
+
return `local/${basename(dir).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── action body capture + redaction (inlined mirror of describe.ts, since this
|
|
330
|
+
// dependency-free hook cannot import the workspace) ───────────────────────────
|
|
331
|
+
const ACTION_BODY_MAX = 4000
|
|
332
|
+
const ACTION_BODY_TRUNCATION_MARKER = '\n… [truncated]'
|
|
333
|
+
// Two tiers, mirroring SECRET_REDACTION_RULES in @pushary/contracts. The precise
|
|
334
|
+
// rules only match real credential shapes, so they are safe on a line a human
|
|
335
|
+
// reads: a git SHA, a path and prose all survive. The high-entropy catch-all
|
|
336
|
+
// over-redacts by design and is therefore only ever applied to a full body dump.
|
|
337
|
+
//
|
|
338
|
+
// One combined list used to serve both, which meant the only text this gate
|
|
339
|
+
// scrubbed was the action body. The question and the notification body carried
|
|
340
|
+
// the raw command.
|
|
341
|
+
const capActionBody = (text) =>
|
|
342
|
+
text.length <= ACTION_BODY_MAX ? text : `${text.slice(0, ACTION_BODY_MAX - ACTION_BODY_TRUNCATION_MARKER.length)}${ACTION_BODY_TRUNCATION_MARKER}`
|
|
343
|
+
const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
|
|
344
|
+
|
|
345
|
+
// VS Code's terminal tool has used more than one field name for the command, and
|
|
346
|
+
// a plain `tool_input` object is not guaranteed. Read the known spellings and
|
|
347
|
+
// treat anything else as "no command", which fast-passes.
|
|
348
|
+
export const extractCommand = (toolInput) => {
|
|
349
|
+
if (!toolInput || typeof toolInput !== 'object') return ''
|
|
350
|
+
for (const field of ['command', 'commandLine', 'command_line', 'cmd', 'script', 'input']) {
|
|
351
|
+
const value = toolInput[field]
|
|
352
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
353
|
+
}
|
|
354
|
+
return ''
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Every named tool reaches policy, as it does through the native bridge. A local
|
|
358
|
+
// allowlist would silently bypass new tools and user-authored standing denials.
|
|
359
|
+
export const shouldGate = (toolName, toolInput) => {
|
|
360
|
+
if (typeof toolName !== 'string' || !toolName.trim() || toolName.startsWith('mcp__pushary__') || toolName.startsWith('mcp_pushary_')) return null
|
|
361
|
+
if (TERMINAL_TOOLS.has(normalizeToolName(toolName))) {
|
|
362
|
+
const command = extractCommand(toolInput)
|
|
363
|
+
if (command) return command
|
|
364
|
+
}
|
|
365
|
+
return `${toolName}: ${JSON.stringify(toolInput ?? {})}`
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── network diagnosis ────────────────────────────────────────────────────────
|
|
369
|
+
//
|
|
370
|
+
// This gate is a dependency-free .mjs, so unlike the CLI it cannot install
|
|
371
|
+
// undici's EnvHttpProxyAgent and its fetch ignores HTTP_PROXY entirely. Node
|
|
372
|
+
// gained a native equivalent in 24 (NODE_USE_ENV_PROXY), but that is read at
|
|
373
|
+
// startup, so a script cannot switch it on for itself.
|
|
374
|
+
//
|
|
375
|
+
// The gate already fails safe: any error here hands the decision to the editor's
|
|
376
|
+
// own prompt. The cost is therefore not a blocked command, it is SILENCE. On a
|
|
377
|
+
// corporate network every approval quietly stops reaching the phone and the only
|
|
378
|
+
// trace is one stderr line nobody reads. So when a proxy is configured and the
|
|
379
|
+
// network call fails, say which of those two it is.
|
|
380
|
+
export const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']
|
|
381
|
+
|
|
382
|
+
export const proxyConfigured = (env = process.env) =>
|
|
383
|
+
PROXY_VARS.some(name => (env[name] ?? '').trim() !== '')
|
|
384
|
+
|
|
385
|
+
export const describeNetworkFailure = (error, env = process.env) => {
|
|
386
|
+
const detail = error?.message ?? String(error)
|
|
387
|
+
if (!proxyConfigured(env)) return detail
|
|
388
|
+
const enabled = (env.NODE_USE_ENV_PROXY ?? '').trim() !== ''
|
|
389
|
+
if (enabled) return detail
|
|
390
|
+
return `${detail} (a proxy is set in HTTP_PROXY/HTTPS_PROXY and this hook cannot use it; on Node 24+ set NODE_USE_ENV_PROXY=1 in the editor's environment)`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── ask / wait ────────────────────────────────────────────────────────────────
|
|
394
|
+
// Redacted, not raw. This is the text a human reads on a lock screen and in
|
|
395
|
+
// Slack, and it used to be the command verbatim: `curl -H "Authorization:
|
|
396
|
+
// Bearer ..."` left the machine and landed in the question. The server scrubs
|
|
397
|
+
// this field too, but a credential should never travel to be scrubbed on
|
|
398
|
+
// arrival, and the notify body below was scrubbed nowhere at all.
|
|
399
|
+
// ask_user accepts a target of at most 80 characters (TOOL_TARGET_MAX_LENGTH in
|
|
400
|
+
// @pushary/contracts). A longer one fails the whole ask.
|
|
401
|
+
const TOOL_TARGET_MAX = 80
|
|
402
|
+
|
|
403
|
+
// A file change is routed and matched by its path, so the ask carries the path the
|
|
404
|
+
// gate judged, resolved against cwd the way the server resolves it. A path too long
|
|
405
|
+
// for ask_user is left out and the server's own target is used instead.
|
|
406
|
+
const filePathTarget = (path, cwd) => {
|
|
407
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
408
|
+
const full = cwd && !isAbsolute(path) ? resolve(cwd, path) : path
|
|
409
|
+
return full.length <= TOOL_TARGET_MAX ? full : undefined
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ask_user takes toolPath as an absolute POSIX path of at most 4096 characters and
|
|
413
|
+
// rejects the whole ask for anything else.
|
|
414
|
+
const TOOL_PATH_MAX = 4096
|
|
415
|
+
|
|
416
|
+
// The exact path, so routing and path rules still match a file whose path is too
|
|
417
|
+
// long for toolTarget. Sent only when it is an absolute POSIX path: a relative path
|
|
418
|
+
// with no absolute cwd, or a Windows path, is left out rather than failing the ask.
|
|
419
|
+
const filePathForAsk = (path, cwd) => {
|
|
420
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
421
|
+
if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\')) return undefined
|
|
422
|
+
const full = path.startsWith('/') ? path
|
|
423
|
+
: typeof cwd === 'string' && cwd.startsWith('/') ? posix.resolve(cwd, path) : undefined
|
|
424
|
+
return full && full.length <= TOOL_PATH_MAX ? full : undefined
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// The canonical tool and target from a gate verdict, taken by name. Anything else a
|
|
428
|
+
// server adds to questionContext must never overwrite what this gate asks with.
|
|
429
|
+
const canonicalQuestion = (context) => ({
|
|
430
|
+
toolName: typeof context?.toolName === 'string' && context.toolName ? context.toolName : undefined,
|
|
431
|
+
toolTarget: typeof context?.toolTarget === 'string' && context.toolTarget ? context.toolTarget : undefined,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
// ask_user caps scopePath at SCOPE_PATH_MAX_LENGTH and blocker at
|
|
435
|
+
// DECISION_LINE_MAX. The server derives a cwd-relative scope path that can be
|
|
436
|
+
// longer than 200, so an over-length value must be DROPPED rather than sent:
|
|
437
|
+
// ask_user rejects the whole call, this gate cannot parse the error, and the
|
|
438
|
+
// action fails closed. Dropping costs only the widening on approval.
|
|
439
|
+
const SCOPE_PATH_MAX = 200
|
|
440
|
+
const BLOCKER_MAX = 500
|
|
441
|
+
|
|
442
|
+
const withinCap = (value, cap) =>
|
|
443
|
+
typeof value === 'string' && value && value.length <= cap ? value : undefined
|
|
444
|
+
|
|
445
|
+
/** The scope breach behind an ask, when the verdict says the ask exists for one. */
|
|
446
|
+
const scopeFromVerdict = (verdict) => ({
|
|
447
|
+
scopePath: withinCap(verdict?.scopePath, SCOPE_PATH_MAX),
|
|
448
|
+
scopeReason: withinCap(verdict?.scopeReason, BLOCKER_MAX),
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
// The tools the server targets by file (FILE_TARGET_TOOLS in @pushary/contracts).
|
|
452
|
+
const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
|
|
453
|
+
|
|
454
|
+
// The same fields the server reads a VS Code file tool's path from.
|
|
455
|
+
const extractFilePath = (toolInput) => {
|
|
456
|
+
if (!toolInput || typeof toolInput !== 'object') return undefined
|
|
457
|
+
const path = toolInput.file_path ?? toolInput.filePath ?? toolInput.path
|
|
458
|
+
return typeof path === 'string' && path ? path : undefined
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const askArgs = (command, project, ident) => ({
|
|
462
|
+
question: `Allow this action?\n\n${redactSecrets(command)}`,
|
|
463
|
+
type: 'confirm',
|
|
464
|
+
context: `VS Code agent wants to run this in ${project}`,
|
|
465
|
+
agentName: ident.agentName,
|
|
466
|
+
sessionId: ident.sessionId,
|
|
467
|
+
machineId: ident.machineId,
|
|
468
|
+
repoKey: ident.repoKey,
|
|
469
|
+
toolName: ident.toolName ?? 'Bash',
|
|
470
|
+
toolTarget: ident.toolTarget,
|
|
471
|
+
...(ident.toolPath ? { toolPath: ident.toolPath } : {}),
|
|
472
|
+
// Carried from the verdict. Without scopePath the server cannot widen the
|
|
473
|
+
// ratified contract when the user approves, so every further file in the same
|
|
474
|
+
// area asks again; without the blocker the card never says that a boundary the
|
|
475
|
+
// user personally agreed to is the reason for asking.
|
|
476
|
+
...(ident.scopePath ? { scopePath: ident.scopePath } : {}),
|
|
477
|
+
...(ident.scopeReason ? { blocker: ident.scopeReason } : {}),
|
|
478
|
+
actionBody: deriveActionBody(command),
|
|
479
|
+
wait: false,
|
|
480
|
+
waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
|
|
484
|
+
while (Date.now() < deadlineMs) {
|
|
485
|
+
const remaining = clamp(deadlineMs - Date.now(), 1_000, WAIT_CHUNK_MS)
|
|
486
|
+
try {
|
|
487
|
+
const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: remaining })
|
|
488
|
+
if (answer?.error) return STOPPED
|
|
489
|
+
if (answer?.answered || stopped(answer) || (answer?.status && answer.status !== 'pending')) return answer
|
|
490
|
+
} catch {
|
|
491
|
+
return { answered: false, handoffAction: 'stop' }
|
|
492
|
+
}
|
|
493
|
+
await sleep(Math.min(POLL_GAP_MS, Math.max(0, deadlineMs - Date.now())))
|
|
494
|
+
}
|
|
495
|
+
return { answered: false }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const fromTimeoutAction = (action, deniedReason) =>
|
|
499
|
+
action === 'approve' ? ALLOW : action === 'deny' ? deny(deniedReason) : ask()
|
|
500
|
+
|
|
501
|
+
const DENIED = 'The user denied this command via a Pushary push approval. Do not run it. Propose an alternative or ask how to proceed.'
|
|
502
|
+
|
|
503
|
+
const fromAnswer = (answer) => {
|
|
504
|
+
activeQuestion = undefined
|
|
505
|
+
if (answer.value === 'defer') return ask()
|
|
506
|
+
return answer.value === 'yes' ? ALLOW : deny(DENIED)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const stopped = (result) => result?.handoffAction === 'stop'
|
|
510
|
+
|| ['cancelled', 'unavailable', 'stopped', 'missing'].includes(result?.status)
|
|
511
|
+
const STOPPED = { answered: false, handoffAction: 'stop' }
|
|
512
|
+
const STOP_REASON = 'This approval was cancelled or its state could not be verified. Do not run the action; wait for a new user instruction.'
|
|
513
|
+
|
|
514
|
+
const withdrawQuestion = async (apiKey, correlationId) => {
|
|
515
|
+
try {
|
|
516
|
+
const cancelled = await callTool(apiKey, 'cancel_question', { correlationId }, WITHDRAW_TIMEOUT_MS)
|
|
517
|
+
if (stopped(cancelled)) return STOPPED
|
|
518
|
+
if (cancelled?.cancelled === true) return { answered: false }
|
|
519
|
+
const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: 1_000 }, WITHDRAW_TIMEOUT_MS)
|
|
520
|
+
if (answer?.answered) return answer
|
|
521
|
+
return ['expired', 'missing'].includes(answer?.status) ? { answered: false } : STOPPED
|
|
522
|
+
} catch {
|
|
523
|
+
return STOPPED
|
|
524
|
+
} finally {
|
|
525
|
+
activeQuestion = undefined
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const handedOff = (asked) => asked.suppressed || asked.status === 'terminal' || asked.status === 'notified'
|
|
530
|
+
|| asked.handoffAction === 'cancel_then_ask_in_current_client'
|
|
531
|
+
|
|
532
|
+
const handoffMessage = (asked) =>
|
|
533
|
+
asked.suppressed ? 'You are at the keyboard, continue with the agent’s permissions.' : 'Continue with the agent’s permissions.'
|
|
534
|
+
|
|
535
|
+
// push_only: wait up to the policy timeout, then apply the timeout action.
|
|
536
|
+
const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, timeoutAction) => {
|
|
537
|
+
let asked
|
|
538
|
+
const args = { ...askArgs(command, project, ident), requestId: randomUUID() }
|
|
539
|
+
try {
|
|
540
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
|
|
541
|
+
} catch {
|
|
542
|
+
return deny('Pushary could not create a verifiable approval. Retry the action.')
|
|
543
|
+
}
|
|
544
|
+
if (stopped(asked)) return deny(STOP_REASON)
|
|
545
|
+
if (asked?.answered) return fromAnswer(asked)
|
|
546
|
+
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
547
|
+
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
548
|
+
|
|
549
|
+
// No device first. The server checks for a channel before any quiet mode, and
|
|
550
|
+
// that reply carries the same handoffAction as a quiet handoff.
|
|
551
|
+
if (asked.noDevices) {
|
|
552
|
+
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
553
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
554
|
+
if (late.answered) return fromAnswer(late)
|
|
555
|
+
return ask('No device connected, approve here.')
|
|
556
|
+
}
|
|
557
|
+
// Keyboard bypass: the user is at the keyboard, so VS Code's own prompt is the
|
|
558
|
+
// faster channel.
|
|
559
|
+
if (handedOff(asked)) {
|
|
560
|
+
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
561
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
562
|
+
if (late.answered) return fromAnswer(late)
|
|
563
|
+
return ask(handoffMessage(asked))
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
|
|
567
|
+
const cap = Math.min(realMs, MAX_BLOCK_MS)
|
|
568
|
+
const deadline = Date.now() + cap
|
|
569
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
|
|
570
|
+
if (stopped(answer)) {
|
|
571
|
+
await withdrawQuestion(apiKey, asked.correlationId)
|
|
572
|
+
return deny(STOP_REASON)
|
|
573
|
+
}
|
|
574
|
+
if (answer.answered) return fromAnswer(answer)
|
|
575
|
+
|
|
576
|
+
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
577
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
578
|
+
if (late.answered) return fromAnswer(late)
|
|
579
|
+
|
|
580
|
+
// If VS Code's hook limit cut us off before the configured timeout, hand off to
|
|
581
|
+
// VS Code's own prompt rather than misapplying the policy's timeout action.
|
|
582
|
+
if (cap >= realMs && Date.now() >= deadline) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
|
|
583
|
+
return ask()
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// push_first: race the push for a short window, then fall back to VS Code's prompt.
|
|
587
|
+
const handlePushFirst = async (apiKey, command, project, ident, pushFirstSeconds) => {
|
|
588
|
+
let asked
|
|
589
|
+
const args = { ...askArgs(command, project, ident), requestId: randomUUID() }
|
|
590
|
+
try {
|
|
591
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
|
|
592
|
+
} catch {
|
|
593
|
+
return deny('Pushary could not create a verifiable approval. Retry the action.')
|
|
594
|
+
}
|
|
595
|
+
if (stopped(asked)) return deny(STOP_REASON)
|
|
596
|
+
if (asked?.answered) return fromAnswer(asked)
|
|
597
|
+
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
598
|
+
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
599
|
+
|
|
600
|
+
if (asked.noDevices) {
|
|
601
|
+
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
602
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
603
|
+
if (late.answered) return fromAnswer(late)
|
|
604
|
+
return ask('No device connected, approve here.')
|
|
605
|
+
}
|
|
606
|
+
if (handedOff(asked)) {
|
|
607
|
+
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
608
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
609
|
+
if (late.answered) return fromAnswer(late)
|
|
610
|
+
return ask(handoffMessage(asked))
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
|
|
614
|
+
const deadline = Date.now() + cap
|
|
615
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
|
|
616
|
+
if (stopped(answer)) {
|
|
617
|
+
await withdrawQuestion(apiKey, asked.correlationId)
|
|
618
|
+
return deny(STOP_REASON)
|
|
619
|
+
}
|
|
620
|
+
if (answer.answered) return fromAnswer(answer)
|
|
621
|
+
|
|
622
|
+
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
623
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
624
|
+
if (late.answered) return fromAnswer(late)
|
|
625
|
+
return ask('No answer from your phone in time, so the request was withdrawn there. Approve here.')
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export { handlePushOnly, handlePushFirst, withdrawQuestion }
|
|
629
|
+
|
|
630
|
+
// notify_only: fire an awareness notification, let VS Code's prompt decide.
|
|
631
|
+
const handleNotifyOnly = async (apiKey, command, project, ident) => {
|
|
632
|
+
try {
|
|
633
|
+
await callTool(apiKey, 'send_notification', {
|
|
634
|
+
title: 'Agent needs approval',
|
|
635
|
+
body: redactSecrets(command).slice(0, 180),
|
|
636
|
+
agentName: ident.agentName,
|
|
637
|
+
sessionId: ident.sessionId,
|
|
638
|
+
machineId: ident.machineId,
|
|
639
|
+
})
|
|
640
|
+
} catch {}
|
|
641
|
+
return ask()
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const TELEMETRY_EVENTS = ["PostToolUse", "Stop", "SessionStart", "UserPromptSubmit", "PreCompact", "SubagentStart", "SubagentStop"]
|
|
645
|
+
|
|
646
|
+
const reportTelemetry = async (input, source) => {
|
|
647
|
+
const apiKey = resolveApiKey()
|
|
648
|
+
if (!apiKey) return
|
|
649
|
+
const sentAt = new Date().toISOString()
|
|
650
|
+
const payload = JSON.stringify(input, (_key, value) => typeof value === 'string' ? redactSecretsDeep(value) : value)
|
|
651
|
+
if (Buffer.byteLength(payload) > 262144) return
|
|
652
|
+
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
653
|
+
const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id ?? ''
|
|
654
|
+
const hookId = hash([source, sessionId, input.hook_event_name, sentAt, hash(payload)].join('|')).slice(0, 32)
|
|
655
|
+
try {
|
|
656
|
+
await fetch(`${BASE_URL}/api/agent/hook`, {
|
|
657
|
+
method: 'POST',
|
|
658
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
659
|
+
body: JSON.stringify({ v: 1, machineId: getMachineId(), app: { platform: 'cli', version: 'editor-plugin' },
|
|
660
|
+
envelopes: [{ wire: 1, hookId, source, event: input.hook_event_name, sentAt,
|
|
661
|
+
cwd: input.cwd, repoKey: deriveRepoKey(input.cwd), payload }] }),
|
|
662
|
+
signal: AbortSignal.timeout(5000),
|
|
663
|
+
})
|
|
664
|
+
} catch { /* Telemetry never blocks an agent. */ }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const main = async () => {
|
|
668
|
+
// Backstop: if anything hangs, return "ask" rather than letting the hook time
|
|
669
|
+
// out and leave the agent with no decision at all. Scheduled here rather than
|
|
670
|
+
// at module scope so importing this file for its helpers arms nothing.
|
|
671
|
+
setTimeout(async () => {
|
|
672
|
+
if (!activeQuestion) return respond(deny('Pushary could not finish approval safely. Retry the action.'))
|
|
673
|
+
const late = await withdrawQuestion(activeQuestion.apiKey, activeQuestion.correlationId)
|
|
674
|
+
respond(late.answered ? fromAnswer(late) : deny('Pushary approval expired or could not be withdrawn safely. Retry the action.'))
|
|
675
|
+
}, HARD_GUARD_MS - 5_000).unref()
|
|
676
|
+
|
|
677
|
+
let input
|
|
678
|
+
try {
|
|
679
|
+
const raw = await readStdin()
|
|
680
|
+
// No stdin at all is normal for a probe/dry run, and it is never a risky
|
|
681
|
+
// command, so pass instead of dragging VS Code into a prompt.
|
|
682
|
+
if (!raw.trim()) return respond(PASS)
|
|
683
|
+
// Some launchers prepend a BOM or encoding prefix, which makes a bare
|
|
684
|
+
// JSON.parse throw. The payload is always a JSON object, so parse from the
|
|
685
|
+
// first "{".
|
|
686
|
+
const jsonStart = raw.indexOf('{')
|
|
687
|
+
input = JSON.parse(jsonStart > 0 ? raw.slice(jsonStart) : raw)
|
|
688
|
+
} catch {
|
|
689
|
+
diag('stdin was not valid JSON. Passing this tool call through to VS Code.')
|
|
690
|
+
return respond(PASS)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
input.hook_event_name ??= input.hookEventName
|
|
694
|
+
if (TELEMETRY_EVENTS.includes(input.hook_event_name)) {
|
|
695
|
+
await reportTelemetry(input, 'vscode')
|
|
696
|
+
return respond({})
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Fast path. This runs before every tool the agent uses, so it must stay free
|
|
700
|
+
// of disk and network work.
|
|
701
|
+
const command = shouldGate(input.tool_name, input.tool_input)
|
|
702
|
+
if (!command) return respond(PASS)
|
|
703
|
+
|
|
704
|
+
const apiKey = resolveApiKey()
|
|
705
|
+
if (!apiKey) {
|
|
706
|
+
diag('no API key found (PUSHARY_API_KEY, the plugin .mcp.json, or ~/.pushary/config.json). Run: npx @pushary/agent-hooks@latest setup')
|
|
707
|
+
return respond(
|
|
708
|
+
unresolved(input.tool_name, 'Pushary is not configured: run `npx @pushary/agent-hooks@latest setup` (get a key at https://pushary.com) to route this approval to your phone.')
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const project = basename(input.cwd || process.cwd()) || 'workspace'
|
|
713
|
+
const sessionId = typeof (input.session_id ?? input.sessionId) === 'string' ? (input.session_id ?? input.sessionId) : undefined
|
|
714
|
+
const ident = { agentName: `VS Code - ${project}`, sessionId, machineId: getMachineId(), toolName: input.tool_name, toolInputs: [input.tool_input ?? {}], repoKey: deriveRepoKey(input.cwd) }
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
const verdict = await decide(apiKey, command, input.cwd, sessionId, ident)
|
|
718
|
+
|
|
719
|
+
// No verdict, or one that says nothing: VS Code's own prompt decides, exactly
|
|
720
|
+
// as if this gate were not installed. Never a forced denial on an outage.
|
|
721
|
+
if (!verdict || verdict.kind === 'no_opinion') return respond(unresolved(input.tool_name))
|
|
722
|
+
if (verdict.kind === 'kill') return respond(deny(verdict.reason))
|
|
723
|
+
if (verdict.kind === 'allow') return respond(ALLOW)
|
|
724
|
+
if (verdict.kind === 'deny') return respond(deny(verdict.reason))
|
|
725
|
+
if (verdict.kind !== 'ask') return respond(ask())
|
|
726
|
+
|
|
727
|
+
// Reuse the server's canonical tool and target so asking resolves the same rules
|
|
728
|
+
// as the gate, taken by name so nothing else in questionContext can overwrite this
|
|
729
|
+
// gate's identity. A file change keeps its own path: the server's target for a
|
|
730
|
+
// file is only its extension, which loses what routing and path rules match on.
|
|
731
|
+
const canonical = canonicalQuestion(verdict.questionContext)
|
|
732
|
+
if (canonical.toolName) ident.toolName = canonical.toolName
|
|
733
|
+
const ownFile = FILE_TOOLS.has(ident.toolName) ? extractFilePath(input.tool_input) : undefined
|
|
734
|
+
ident.toolTarget = filePathTarget(ownFile, input.cwd) ?? canonical.toolTarget
|
|
735
|
+
ident.toolPath = filePathForAsk(ownFile, input.cwd)
|
|
736
|
+
Object.assign(ident, scopeFromVerdict(verdict))
|
|
737
|
+
const tool = verdict.policy
|
|
738
|
+
|
|
739
|
+
switch (tool.mode) {
|
|
740
|
+
case 'terminal_only':
|
|
741
|
+
return respond(ask())
|
|
742
|
+
case 'notify_only':
|
|
743
|
+
return respond(await handleNotifyOnly(apiKey, command, project, ident))
|
|
744
|
+
case 'push_only':
|
|
745
|
+
return respond(await handlePushOnly(apiKey, command, project, ident, tool.timeoutSeconds, tool.timeoutAction))
|
|
746
|
+
case 'push_first':
|
|
747
|
+
default:
|
|
748
|
+
return respond(await handlePushFirst(apiKey, command, project, ident, tool.pushFirstSeconds))
|
|
749
|
+
}
|
|
750
|
+
} catch (error) {
|
|
751
|
+
diag(describeNetworkFailure(error))
|
|
752
|
+
return respond(unresolved(input.tool_name))
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Importing this file for its testable helpers must not consume stdin or exit.
|
|
757
|
+
if (!process.env.PUSHARY_GATE_IMPORT) {
|
|
758
|
+
main().catch((error) => {
|
|
759
|
+
diag(`fatal: ${describeNetworkFailure(error)}`)
|
|
760
|
+
respond(ask())
|
|
761
|
+
})
|
|
762
|
+
}
|