@yokira404/dsh-git-guard 0.0.0-stage → 1.0.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/index.js ADDED
@@ -0,0 +1,597 @@
1
+ /**
2
+ * Host half of @yokira404/dsh-git-guard.
3
+ *
4
+ * The plugin owns two switches, drawn in the Client half beside the Session title.
5
+ * Neither carries a colour of its own — the pair is black and white, like every
6
+ * other control on that row — so they are named for what they stop:
7
+ *
8
+ * local blocks the repository-local half — `git add`, `git commit` and
9
+ * friends. Nothing leaves the machine, but it is what fills the local
10
+ * repository a later push would carry;
11
+ * remote blocks the remote half — `git push`, `git remote`, `gh`, a repository
12
+ * URL on the wire.
13
+ *
14
+ * The pair's behaviour is exactly two clicks (both drawn by the Client, which is
15
+ * where a click happens), and each click is a *transition*, never a standing
16
+ * condition:
17
+ *
18
+ * clicking the local switch toggles it; when it lands ON the remote switch is
19
+ * turned on too, and when it lands OFF the remote
20
+ * switch is left exactly as it was;
21
+ * clicking the remote switch toggles it; when it lands ON the local switch is
22
+ * left exactly as it was, and when it lands OFF the
23
+ * local switch is turned off too.
24
+ *
25
+ * Read the second rule from the *guard*, not from the switch: releasing the upload
26
+ * guard releases the local guard with it, because the local writes are exactly what
27
+ * an upload would carry, and a pair that let the upload through while the same bytes
28
+ * stayed frozen on disk would be describing half a decision. Asking for the upload
29
+ * guard is not symmetric — it relaxes nothing, so it moves nothing else, which is
30
+ * also what leaves `local off · remote on` reachable from both-off: "let me keep
31
+ * writing for now, and decide about pushing later" is a way of working the reader
32
+ * asked for, not a hole. Neither rule runs in both directions: switching a guard
33
+ * *on* never releases the other one.
34
+ *
35
+ * There is deliberately no "invariant" over the *stored* value — only rules about
36
+ * the clicks that produce one. An earlier revision encoded one here as a property of
37
+ * every state, and because it was applied on every write rather than on the click
38
+ * that produces it, the local switch could never be turned off again while the
39
+ * remote switch was off. A transition must be applied where the transition happens.
40
+ * (`evidence/selftest.mjs` holds both halves of that: the full click table, all four
41
+ * states still reachable, no click a no-op, and each click's carrying direction
42
+ * checked in every state.)
43
+ *
44
+ * Enforcement lives here and nowhere else: the switch that stops a command has to
45
+ * be the switch that owns the machine doing it, so the rule is installed on the
46
+ * Host's own tool pipeline through `tools/pre-execute` — the documented waterfall
47
+ * that decides allow/deny/cancel *before* a call is dispatched. The Client half is
48
+ * presentation: it draws the two buttons and mirrors the same state back here.
49
+ *
50
+ * That split fixes where the state may live. A browser tab cannot veto a Host tool
51
+ * call, so the authoritative copy is this module's own map, and the Client writes
52
+ * through the package's own HTTP routes on `webServer` (same origin as the Web GUI,
53
+ * no third-party service, and no code generation step). A Session the Client has
54
+ * never reported reads as armed: the safe default is the one that blocks, because
55
+ * the failure this plugin exists to prevent is an upload that already happened.
56
+ *
57
+ * The module is also its own test surface: `classifyShellCommand`, `classifyCall`
58
+ * and `normalizeState` are pure, so `evidence/selftest.mjs` exercises the exact
59
+ * rules the live pipeline installs rather than a paraphrase of them.
60
+ */
61
+
62
+ export const NS = 'dsh-git-guard'
63
+ export const VERSION = '1.0.0'
64
+
65
+ /* ──────────────────────────── switch state ──────────────────────────── */
66
+
67
+ /**
68
+ * The Client's head start for a Session this Host has not been told about. Both
69
+ * switches start on, which is what the reference asks for and what makes the
70
+ * plugin protective the moment it loads.
71
+ */
72
+ export const DEFAULT_STATE = Object.freeze({ local: true, remote: true })
73
+
74
+ /**
75
+ * Coerce anything that crossed the wire into a usable pair. Every combination of
76
+ * the two switches is a real, storable state; this function only makes the field
77
+ * types trustworthy, and deliberately repairs nothing else.
78
+ *
79
+ * The pair's behaviour lives in the two clicks below, not in the stored value.
80
+ * Encoding either of them here as an invariant was a real bug: with the remote
81
+ * switch already off, every later write of `local: false` was repaired back to
82
+ * `true` on both halves, so the local switch could never be turned off again. A
83
+ * rule that describes one transition must not be written as a property of every
84
+ * state.
85
+ */
86
+ export function normalizeState(value) {
87
+ const source = value !== null && typeof value === 'object' ? value : {}
88
+ return {
89
+ local: asBool(source.local, DEFAULT_STATE.local),
90
+ remote: asBool(source.remote, DEFAULT_STATE.remote),
91
+ }
92
+ }
93
+
94
+ function asBool(value, fallback) {
95
+ if (typeof value === 'boolean') return value
96
+ if (value === 'true') return true
97
+ if (value === 'false') return false
98
+ return fallback
99
+ }
100
+
101
+ /**
102
+ * The pair's behaviour is exactly two clicks, and this is both of them. The local
103
+ * click carries the remote switch only when it lands ON; the remote click carries
104
+ * the local switch only when it lands OFF. Neither is a property of a state, so
105
+ * neither is applied anywhere but here — every state stays reachable and nothing is
106
+ * ever locked.
107
+ *
108
+ * The Client carries a byte-identical copy (it cannot import this module), and
109
+ * `evidence/selftest.mjs` asserts the two spellings have not drifted apart.
110
+ *
111
+ * @param {{ local: boolean, remote: boolean }} state - the pair as it stands.
112
+ * @param {'local' | 'remote'} clicked - which switch the reader pressed.
113
+ * @returns {{ local: boolean, remote: boolean }} the pair after that click.
114
+ */
115
+ export function nextState(state, clicked) {
116
+ if (clicked === 'local') {
117
+ const local = state.local !== true
118
+ /* Landing ON turns the remote guard on too; landing OFF leaves it exactly as it was. */
119
+ return { local, remote: local === true ? true : state.remote === true }
120
+ }
121
+ const remote = state.remote !== true
122
+ /* Releasing the upload guard releases the local one with it; asking for it moves nothing. */
123
+ return { local: remote === false ? false : state.local === true, remote }
124
+ }
125
+
126
+ /* ───────────────────────── command classification ───────────────────── */
127
+
128
+ /**
129
+ * Which half of a repository one concrete operation touches.
130
+ *
131
+ * 'local' — writes the local repository object database or its refs
132
+ * (`git add`, `git commit`, `git reset`, `git merge`, …);
133
+ * 'remote' — reads or writes a remote (`git push`, `git fetch`, `git remote`,
134
+ * `git ls-remote`, a repository URL on the wire).
135
+ *
136
+ * `git init`, `git status`, `git log` and `git diff` are deliberately neither:
137
+ * creating a repository and reading it neither uploads anything nor commits to it,
138
+ * and this plugin's own .gitignore promises the user a local repository they can
139
+ * still inspect.
140
+ */
141
+ export const LOCAL_SUBCOMMANDS = Object.freeze([
142
+ 'add',
143
+ 'am',
144
+ 'apply',
145
+ 'commit',
146
+ 'config',
147
+ 'merge',
148
+ 'mv',
149
+ 'notes',
150
+ 'rebase',
151
+ 'reset',
152
+ 'restore',
153
+ 'revert',
154
+ 'rm',
155
+ 'stash',
156
+ 'switch',
157
+ 'tag',
158
+ 'update-index',
159
+ 'update-ref',
160
+ 'checkout-index',
161
+ ])
162
+
163
+ export const REMOTE_SUBCOMMANDS = Object.freeze([
164
+ 'archive',
165
+ 'clone',
166
+ 'fetch',
167
+ 'ls-remote',
168
+ 'pull',
169
+ 'push',
170
+ 'remote',
171
+ 'request-pull',
172
+ 'send-pack',
173
+ 'submodule',
174
+ 'svn',
175
+ ])
176
+
177
+ /**
178
+ * `gh` verbs that reach GitHub. The rest of the CLI — `gh auth`, `gh config`,
179
+ * `gh completion`, `gh help` — is not an upload path and stays allowed, because a
180
+ * guard that blocks a sign-in is a guard the reader turns off.
181
+ */
182
+ const GH_REACHING = new Set([
183
+ 'alias',
184
+ 'api',
185
+ 'cache',
186
+ 'codespace',
187
+ 'extension',
188
+ 'gist',
189
+ 'issue',
190
+ 'org',
191
+ 'pr',
192
+ 'project',
193
+ 'release',
194
+ 'repo',
195
+ 'ruleset',
196
+ 'run',
197
+ 'search',
198
+ 'secret',
199
+ 'ssh-key',
200
+ 'status',
201
+ 'variable',
202
+ 'workflow',
203
+ ])
204
+
205
+ /** Hosts whose traffic is somebody's remote repository. */
206
+ const GIT_HOST = /(?:^|[/.@])(?:github\.com|gitee\.com|gitlab\.com|bitbucket\.org|codeberg\.org|git\.sr\.ht|gitea\.com)(?:[/:]|$)/i
207
+ /**
208
+ * A repository URL, wherever it is spelled: `git@github.com:x/y.git`,
209
+ * `https://github.com/x/y.git`, `ssh://git@host/x/y`.
210
+ *
211
+ * A bare path is deliberately *not* a URL here. `git remote add origin /srv/git/x`
212
+ * is a real thing, but treating any path as remote would make an ordinary
213
+ * `mkdir -p /home/user/app` an upload, and a guard that blocks ordinary work is a
214
+ * guard the reader turns off. A URL therefore has to carry a scheme or `git@`.
215
+ */
216
+ const GIT_URL = /(?:git@[\w.-]+:|(?:https?|ssh|git\+ssh|git):\/\/)\S*/i
217
+ /** A command that carries bytes to a remote endpoint, rather than reading one. */
218
+ const UPLOADERS = new Set(['curl', 'wget', 'invoke-webrequest', 'invoke-restmethod', 'iwr', 'irm'])
219
+ /** The short `gh api` options whose value is a separate word, so it is not the endpoint. */
220
+ const API_SHORT_WITH_VALUE = new Set(['-X', '-f', '-F', '-H', '-q', '-t', '--hostname', '--method', '--raw-field', '--field', '--header', '--jq', '--template', '--input', '--cache'])
221
+
222
+ /**
223
+ * Split a shell command into words, keeping quoted runs whole. A commit message
224
+ * such as `git commit -m "do not push this"` must not read as a push, so the
225
+ * tokenizer is quote-aware and the classifier only ever looks at real words.
226
+ */
227
+ export function shellWords(text) {
228
+ const words = []
229
+ let current = ''
230
+ let quote = null
231
+ let started = false
232
+ for (const character of String(text)) {
233
+ if (quote !== null) {
234
+ if (character === quote) quote = null
235
+ else current += character
236
+ continue
237
+ }
238
+ if (character === '"' || character === "'") {
239
+ quote = character
240
+ started = true
241
+ continue
242
+ }
243
+ if (character === ' ' || character === '\t' || character === '\n' || character === '\r') {
244
+ if (started === true) words.push(current)
245
+ current = ''
246
+ started = false
247
+ continue
248
+ }
249
+ current += character
250
+ started = true
251
+ }
252
+ if (started === true) words.push(current)
253
+ return words
254
+ }
255
+
256
+ /** The program name, stripped of any directory and of a `.exe`/`.cmd` suffix. */
257
+ function programOf(word) {
258
+ const raw = String(word).replace(/\\/g, '/')
259
+ const bare = raw.slice(raw.lastIndexOf('/') + 1)
260
+ return bare.replace(/\.(?:exe|cmd|bat|ps1)$/i, '').toLowerCase()
261
+ }
262
+
263
+ /**
264
+ * Read one `git` invocation and answer which half it touches.
265
+ *
266
+ * Global options are skipped with the arity they actually take, so `git -C /repo
267
+ * push` and `git --git-dir=x commit` are read the same way the real CLI reads
268
+ * them. An option is skipped with its value only when the value is a separate
269
+ * word (`-C /repo`); `-C/repo` and `--git-dir=x` are one word already.
270
+ */
271
+ function classifyGit(words) {
272
+ let index = 1
273
+ while (index < words.length && words[index].startsWith('-') === true) {
274
+ const flag = words[index]
275
+ const takesValueApart = flag === '-C' || flag === '-c' || flag === '--git-dir' || flag === '--work-tree' || flag === '--namespace' || flag === '--exec-path'
276
+ index += takesValueApart === true && index + 1 < words.length ? 2 : 1
277
+ }
278
+ if (index >= words.length) return null
279
+ const command = words[index].toLowerCase()
280
+ /*
281
+ * `git remote -v`, `git remote show origin` — any `git remote` reads or writes
282
+ * the remote table, so the whole verb belongs to the remote half. `git config`
283
+ * is the other way round: `remote.origin.url` is a local edit of a local file,
284
+ * which is exactly the `local` switch's business.
285
+ */
286
+ if (command === 'remote') return 'remote'
287
+ if (REMOTE_SUBCOMMANDS.includes(command) === true) return 'remote'
288
+ if (LOCAL_SUBCOMMANDS.includes(command) === true) return 'local'
289
+ return null
290
+ }
291
+
292
+ /** One `gh` invocation: reaching a repository, or an upload, or neither. */
293
+ function classifyGh(words) {
294
+ let index = 1
295
+ while (index < words.length && words[index].startsWith('-') === true) index += 1
296
+ if (index >= words.length) return null
297
+ const verb = words[index].toLowerCase()
298
+ /*
299
+ * `gh --version` and `gh help` are answered by the CLI itself and never resolve
300
+ * a repository, so they are not upload paths.
301
+ */
302
+ if (verb === 'help' || verb === '--version' || verb === '-v') return null
303
+ if (GH_REACHING.has(verb) === false) return null
304
+ /*
305
+ * `gh api -X GET /user` is a read of the account, not of a repository: only an
306
+ * endpoint that names one counts. Everything else under a reaching verb does.
307
+ * The lookbehind matters — `/repos` is a repository endpoint and `/user/repos`
308
+ * is the current account's repository collection, so both count, while
309
+ * `/users/octocat` on its own does not.
310
+ */
311
+ if (verb === 'api') {
312
+ /*
313
+ * `gh api` spells its arguments as `-X POST /repos/x/y/git/refs`, so the
314
+ * endpoint is the first positional word — which means the flags in front of it
315
+ * have to be walked with the arity they really take, or `POST` reads as the
316
+ * endpoint and `/repos/…` is never seen.
317
+ */
318
+ let cursor = index + 1
319
+ while (cursor < words.length) {
320
+ const word = words[cursor]
321
+ if (word === '--') {
322
+ cursor += 1
323
+ break
324
+ }
325
+ if (word.startsWith('-') === false) break
326
+ const inline = word.includes('=')
327
+ cursor += inline === true || API_SHORT_WITH_VALUE.has(word) === false ? 1 : 2
328
+ }
329
+ if (cursor >= words.length) return null
330
+ return /(?:\/repos\b|\/orgs\/|\/gists\b|\/user\/repos\b)/i.test(words[cursor]) === true ? 'remote' : null
331
+ }
332
+ return 'remote'
333
+ }
334
+
335
+ /**
336
+ * Classify one shell command line. Chained commands are classified segment by
337
+ * segment and the *strongest* finding wins, so `git add -A && git push` is a
338
+ * remote operation while `git status && git log` is nothing at all.
339
+ *
340
+ * Returns 'remote' | 'local' | null.
341
+ */
342
+ export function classifyShellCommand(text) {
343
+ if (typeof text !== 'string' || text.trim() === '') return null
344
+ const words = shellWords(text)
345
+ if (words.length === 0) return null
346
+
347
+ /*
348
+ * Every word is a candidate program name, because a real invocation can be
349
+ * prefixed: `sudo git push`, `env FOO=1 git commit`, `nohup git fetch`. A word
350
+ * is only read as `git`/`gh` when it *is* that program, which is what makes the
351
+ * scan safe to run over the whole line.
352
+ *
353
+ * The strongest finding wins. `git remote add origin <url>` is remote because
354
+ * its verb says so; `git config remote.origin.url <url>` is local because *its*
355
+ * verb says so, even though a repository URL is spelled on the line — a config
356
+ * write is a local write, and calling it an upload would block it under the
357
+ * wrong switch.
358
+ */
359
+ let best = null
360
+ let urlSeen = false
361
+ for (let index = 0; index < words.length; index += 1) {
362
+ const program = programOf(words[index])
363
+ if (program === 'git') {
364
+ const found = classifyGit(words.slice(index))
365
+ if (found === 'remote') return 'remote'
366
+ if (found === 'local') best = 'local'
367
+ } else if (program === 'gh') {
368
+ const found = classifyGh(words.slice(index))
369
+ if (found === 'remote') return 'remote'
370
+ if (found === 'local') best = 'local'
371
+ } else if (UPLOADERS.has(program) === true) {
372
+ /*
373
+ * A fetcher is only an upload when the wire is somebody's repository, and
374
+ * that is precisely what the URL check below reads. Asking again here keeps
375
+ * `curl https://example.com` an ordinary download.
376
+ */
377
+ if (GIT_HOST.test(text) === true) urlSeen = true
378
+ }
379
+ }
380
+ if (GIT_URL.test(text) === true && GIT_HOST.test(text) === true) urlSeen = true
381
+ /*
382
+ * A repository URL with no verb to explain it is somebody reaching a repository
383
+ * host directly, so it reports 'remote' and never 'local': a URL does not write
384
+ * a local repository object. A URL that *is* explained by a verb has already
385
+ * been answered above, which is why this is the last word rather than the first.
386
+ */
387
+ if (best !== null) return best
388
+ return urlSeen === true ? 'remote' : null
389
+ }
390
+
391
+ /* ──────────────────────────── call classification ───────────────────── */
392
+
393
+ /**
394
+ * Every field of a tool call that can carry a command line, most specific first.
395
+ * A call is only ever as dangerous as its most dangerous field, so all of them
396
+ * are read rather than the first one found.
397
+ */
398
+ const COMMAND_FIELDS = Object.freeze(['command', 'script', 'cmd', 'code', 'input', 'content'])
399
+
400
+ function collectText(value, out, depth) {
401
+ if (depth > 4 || out.length > 64) return
402
+ if (typeof value === 'string') {
403
+ out.push(value)
404
+ return
405
+ }
406
+ if (Array.isArray(value) === true) {
407
+ for (const item of value) collectText(item, out, depth + 1)
408
+ return
409
+ }
410
+ if (value !== null && typeof value === 'object') {
411
+ for (const key of Object.keys(value)) collectText(value[key], out, depth + 1)
412
+ }
413
+ }
414
+
415
+ /** The command-like strings one tool call carries, deduplicated. */
416
+ export function commandTexts(args) {
417
+ const out = []
418
+ if (args === null || typeof args !== 'object') return out
419
+ for (const field of COMMAND_FIELDS) {
420
+ if (field in args === false) continue
421
+ collectText(args[field], out, 0)
422
+ }
423
+ const seen = new Set()
424
+ return out.filter((text) => (seen.has(text) === true ? false : (seen.add(text), true)))
425
+ }
426
+
427
+ /** How a call reads once classified: which verb, which half, and why. */
428
+ export function classifyCall(args) {
429
+ let best = null
430
+ let evidence = null
431
+ for (const text of commandTexts(args)) {
432
+ const operation = classifyShellCommand(text)
433
+ if (operation === 'remote') return { operation: 'remote', evidence: text }
434
+ if (operation === 'local' && best === null) {
435
+ best = 'local'
436
+ evidence = text
437
+ }
438
+ }
439
+ return best === null ? null : { operation: best, evidence }
440
+ }
441
+
442
+ /**
443
+ * The whole rule, in one place: does this pending call survive the two switches?
444
+ *
445
+ * Each switch answers for its own half and nothing else, which is what makes the
446
+ * pair independent: `local` off means local writes pass even while `remote` still
447
+ * blocks uploads, and a state that survived `normalizeState` can never be the other
448
+ * way round.
449
+ */
450
+ export function decide(state, { operation }) {
451
+ if (state.remote === true && operation === 'remote') return 'remote'
452
+ if (state.local === true && operation === 'local') return 'local'
453
+ return null
454
+ }
455
+
456
+ /**
457
+ * The denial the model receives. It names the switch by its position in the
458
+ * Session header rather than by a colour, because the pair has none: the reader
459
+ * finds it by looking at the same two squares the message points at.
460
+ */
461
+ export function denialMessage(switchName, evidence) {
462
+ const command = evidence === null || evidence === undefined ? '' : '`' + String(evidence).slice(0, 300) + '`'
463
+ const where = switchName === 'remote' ? '右侧的远端仓库开关' : '左侧的本地仓库开关'
464
+ const what =
465
+ switchName === 'remote'
466
+ ? '上传远端仓库的操作(git push / git remote / gh 等)'
467
+ : '写入本地仓库的操作(git add / git commit 等)'
468
+ return (
469
+ '已阻止:DSH 的「Git 仓库保护」插件当前开着' +
470
+ where +
471
+ ',' +
472
+ what +
473
+ '被拒绝。\n' +
474
+ (command === '' ? '' : '被拦截的命令:' + command + '\n') +
475
+ '这是用户有意设置的开关,不是故障:不要改用其他命令、其他工具或直接调用 API 绕过它,' +
476
+ '也不要尝试关闭该开关。请把这一步作为「未执行」如实报告给用户,并说明用户可以点击会话标题栏的两个方形按钮自行放行。'
477
+ )
478
+ }
479
+
480
+ /* ─────────────────────────────── host ───────────────────────────────── */
481
+
482
+ /**
483
+ * Install the rule and the Client's read/write route.
484
+ *
485
+ * Nothing here is awaited: the activation must not depend on a Client being
486
+ * connected, because the switch that protects a headless Session is exactly the
487
+ * one whose Client is not there to confirm it.
488
+ */
489
+ export function apply(ctx) {
490
+ const states = new Map()
491
+ const known = new Set()
492
+ let blocked = 0
493
+ let lastBlock = null
494
+
495
+ const stateOf = (sessionId) => {
496
+ if (typeof sessionId !== 'string' || sessionId === '') return DEFAULT_STATE
497
+ const held = states.get(sessionId)
498
+ if (held !== undefined) return held
499
+ /*
500
+ * A Session the Client has never reported is armed. Writing the default into
501
+ * the map is what makes the answer stable and cheap; the first write from the
502
+ * Client replaces it.
503
+ */
504
+ states.set(sessionId, { ...DEFAULT_STATE })
505
+ return states.get(sessionId)
506
+ }
507
+
508
+ const write = (sessionId, value) => {
509
+ const next = normalizeState(value)
510
+ if (typeof sessionId === 'string' && sessionId !== '') {
511
+ states.set(sessionId, next)
512
+ known.add(sessionId)
513
+ }
514
+ return next
515
+ }
516
+
517
+ /* 1. the rule itself, on the documented pre-dispatch waterfall */
518
+ ctx.on('tools/pre-execute', async (exec, next) => {
519
+ const state = stateOf(exec !== null && exec !== undefined && exec.agent !== undefined && exec.agent !== null ? exec.agent.id : undefined)
520
+ if (state.local === false && state.remote === false) return next()
521
+ const finding = classifyCall(exec !== null && exec !== undefined ? exec.arguments : undefined)
522
+ if (finding === null) return next()
523
+ const switchName = decide(state, finding)
524
+ if (switchName === null) return next()
525
+ blocked += 1
526
+ lastBlock = { switch: switchName, tool: String(exec.name), at: Date.now() }
527
+ return {
528
+ kind: 'deny',
529
+ reason: denialMessage(switchName, finding.evidence),
530
+ info: { name: 'GitGuardBlocked', code: 'DSH_GIT_GUARD_BLOCKED', reason: switchName },
531
+ }
532
+ })
533
+
534
+ /* 2. the Client's own two routes, on the Web composition's HTTP carrier */
535
+ ctx.inject(['webServer'], (scope) => {
536
+ const json = (res, status, body) => {
537
+ const payload = JSON.stringify(body)
538
+ res.writeHead(status, {
539
+ 'content-type': 'application/json; charset=utf-8',
540
+ 'cache-control': 'no-store',
541
+ 'content-length': Buffer.byteLength(payload),
542
+ })
543
+ res.end(payload)
544
+ }
545
+ const sessionOf = (url) => {
546
+ const value = url.searchParams.get('session')
547
+ return value === null ? '' : value
548
+ }
549
+ scope.effect(
550
+ () =>
551
+ scope.webServer.register({
552
+ kind: 'exact',
553
+ path: '/dsh-git-guard/state',
554
+ handler: async (req, res) => {
555
+ if (req.method === 'GET') {
556
+ const session = sessionOf(new URL(req.url ?? '/', 'http://127.0.0.1'))
557
+ return json(res, 200, { ok: true, state: stateOf(session), version: VERSION, stats: { blocked, lastBlock } })
558
+ }
559
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
560
+ let body = ''
561
+ for await (const chunk of req) {
562
+ body += chunk
563
+ /* A Client that streams an unbounded body is not a Client of ours. */
564
+ if (body.length > 16384) {
565
+ return json(res, 413, { ok: false, error: 'payload-too-large' })
566
+ }
567
+ }
568
+ let parsed = null
569
+ try {
570
+ parsed = JSON.parse(body === '' ? '{}' : body)
571
+ } catch {
572
+ return json(res, 400, { ok: false, error: 'invalid-json' })
573
+ }
574
+ const fromQuery = sessionOf(new URL(req.url ?? '/', 'http://127.0.0.1'))
575
+ const session = fromQuery !== '' ? fromQuery : String(parsed?.session ?? '')
576
+ const next = write(session, parsed?.state)
577
+ return json(res, 200, { ok: true, state: next, version: VERSION })
578
+ },
579
+ }),
580
+ NS + ':route',
581
+ )
582
+ })
583
+
584
+ /*
585
+ * Diagnostics for the plugin's own verification and for anyone reading the
586
+ * process: reading it does not change state, and the map is exposed as a plain
587
+ * object so a probe never holds a live reference into the guard.
588
+ */
589
+ ctx.provide(NS, {
590
+ version: VERSION,
591
+ stateOf: (sessionId) => ({ ...stateOf(sessionId) }),
592
+ write,
593
+ snapshot: () => ({ blocked, lastBlock, sessions: Object.fromEntries([...states].map(([id, value]) => [id, { ...value }])), known: [...known] }),
594
+ classify: (args) => classifyCall(args),
595
+ decide: (state, finding) => decide(normalizeState(state), finding),
596
+ })
597
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git Repository Guard",
4
+ "description": "Two switches at the right of the Session header. The left one stops writes to your local git repository (git add, git commit); the right one stops uploads to a remote (git push, git remote, gh). Both start on, and switching a guard on never releases the other. The block happens before a command runs."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git 仓库保护",
4
+ "description": "会话标题栏右侧的一对开关:左开关拦住写入本地 git 仓库的操作(git add、git commit 等),右开关拦住上传远端的操作(git push、git remote、gh 等),两个都默认开启。关闭远端时本地会跟着关闭,打开一侧则不会关掉另一侧。拦截发生在命令真正执行之前,被拒绝的操作会明确告知 agent 不要绕过。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@yokira404/dsh-git-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "description": "Two switches at the right of the Session header. The left one stops writes to your local git repository (git add, git commit); the right one stops uploads to a remote (git push, git remote, gh). Both start on, and switching a guard on never releases the other. The block happens before a command runs.",
6
+ "type": "module",
7
+ "icon": "./icon.svg",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Yokira404/dsh-git-guard.git"
11
+ },
12
+ "keywords": [
13
+ "dsh",
14
+ "dsh-plugin",
15
+ "deepseek-harness",
16
+ "git",
17
+ "guard",
18
+ "push"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "exports": {
24
+ ".": "./index.js",
25
+ "./client": "./client.js",
26
+ "./package.json": "./package.json",
27
+ "./cordis.patch.yml": "./cordis.patch.yml",
28
+ "./locale/*.json": "./locale/*.json"
29
+ },
30
+ "files": [
31
+ "index.js",
32
+ "client.js",
33
+ "cordis.patch.yml",
34
+ "icon.svg",
35
+ "locale/*.json",
36
+ "docs",
37
+ "README.md",
38
+ "README.zh.md",
39
+ "LICENSE"
40
+ ],
41
+ "dsh": {
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "platform": "web",
47
+ "inject": [
48
+ "@deepseek-ai/dsh-client-ui-conversation"
49
+ ]
50
+ }
51
+ },
52
+ "meta": {
53
+ "title": "Git Repository Guard",
54
+ "description": "Two switches at the right of the Session header. The left one stops writes to your local git repository (git add, git commit); the right one stops uploads to a remote (git push, git remote, gh). Both start on, and switching a guard on never releases the other. The block happens before a command runs."
55
+ }
56
+ }