typeclaw 0.34.1 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/agent/plugin-tools.ts +71 -13
- package/src/agent/provider-error.ts +10 -0
- package/src/agent/session-origin.ts +26 -0
- package/src/agent/tools/channel-disengage.ts +13 -9
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
- package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
- package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
- package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
- package/src/channels/adapters/github/inbound.ts +41 -3
- package/src/channels/adapters/slack-bot.ts +17 -9
- package/src/channels/continuation-willingness.ts +331 -0
- package/src/channels/github-review-claim.ts +105 -0
- package/src/channels/github-token-bridge.ts +7 -0
- package/src/channels/router.ts +103 -24
- package/src/cli/channel.ts +102 -11
- package/src/cli/qr.ts +130 -0
- package/src/config/config.ts +98 -2
- package/src/container/start.ts +12 -0
- package/src/init/dockerfile.ts +64 -0
- package/src/init/line-auth.ts +8 -3
- package/src/inspect/live.ts +128 -13
- package/src/plugin/context.ts +5 -1
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/types.ts +1 -0
- package/src/run/index.ts +1 -0
- package/src/sandbox/availability.ts +87 -19
- package/src/sandbox/build.ts +27 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/package-install.ts +23 -0
- package/src/sandbox/policy.ts +31 -0
- package/src/sandbox/symlinks.ts +34 -0
- package/src/sandbox/writable-zones.ts +164 -4
- package/src/server/index.ts +5 -1
- package/src/shared/protocol.ts +22 -11
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
- package/typeclaw.schema.json +32 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"cron-parser": "^5.5.0",
|
|
54
54
|
"jq-wasm": "^1.1.0-jq-1.8.1",
|
|
55
55
|
"jsdom": "^29.0.2",
|
|
56
|
+
"qrcode": "^1.5.4",
|
|
56
57
|
"turndown": "^7.2.4",
|
|
57
58
|
"zod": "^4.3.6"
|
|
58
59
|
},
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
"@types/bun": "latest",
|
|
62
63
|
"@types/jsdom": "^28.0.1",
|
|
63
64
|
"@types/proper-lockfile": "^4.1.4",
|
|
65
|
+
"@types/qrcode": "^1.5.6",
|
|
64
66
|
"@types/sinonjs__fake-timers": "^15.0.1",
|
|
65
67
|
"@types/turndown": "^5.0.6",
|
|
66
68
|
"@types/ws": "^8.18.1",
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
checkNonWorkspaceWriteGuard,
|
|
24
24
|
checkSkillAuthoringGuard,
|
|
25
25
|
} from '@/bundled-plugins/guard/policy'
|
|
26
|
-
import { config } from '@/config/config'
|
|
26
|
+
import { config, getSandboxWritablePathSpecs } from '@/config/config'
|
|
27
27
|
import type { PermissionService } from '@/permissions/permissions'
|
|
28
28
|
import type {
|
|
29
29
|
BuiltinToolRef,
|
|
@@ -37,14 +37,19 @@ import type {
|
|
|
37
37
|
} from '@/plugin'
|
|
38
38
|
import {
|
|
39
39
|
buildSandboxedCommand,
|
|
40
|
-
canBindProcSafely,
|
|
41
40
|
canMountRealProc,
|
|
41
|
+
DEFAULT_SANDBOX_ENV,
|
|
42
42
|
ensureBwrapAvailable,
|
|
43
43
|
ensureSessionTmpDir,
|
|
44
|
+
getProcBindSafetyVerdict,
|
|
45
|
+
isPackageInstallCommand,
|
|
44
46
|
mapVirtualTmpPath,
|
|
45
47
|
resolveHiddenPaths,
|
|
48
|
+
resolvePackageInstallZones,
|
|
49
|
+
resolveProcBindSafetyWithRetry,
|
|
46
50
|
resolveProcSelfExe,
|
|
47
51
|
resolveProtectedZones,
|
|
52
|
+
resolveSandboxSymlinks,
|
|
48
53
|
resolveWritableZones,
|
|
49
54
|
type SandboxProcStrategy,
|
|
50
55
|
subtractMasked,
|
|
@@ -590,7 +595,17 @@ async function applyBashSandbox(
|
|
|
590
595
|
// (their masks are empty) and keep full unsandboxed access. subtractMasked
|
|
591
596
|
// drops any writable zone masked for this role so an RW bind never re-exposes a
|
|
592
597
|
// hidden path (e.g. a guest's masked workspace/).
|
|
593
|
-
|
|
598
|
+
// config.sandbox.* is read from the BOOT-TIME snapshot, not getConfig():
|
|
599
|
+
// sandbox is restart-required, so the writable surface AND the in-jail symlinks
|
|
600
|
+
// must stay coherent with the boot-time bwrap/capability decisions and with the
|
|
601
|
+
// entrypoint shim's symlink creation (same contract as resolveProcStrategy's
|
|
602
|
+
// read of config.sandbox.realProc below). getSandboxWritablePathSpecs folds
|
|
603
|
+
// every sandbox.symlinks[].to into the writable set so the symlink target is
|
|
604
|
+
// writable without the operator also listing it under writablePaths.
|
|
605
|
+
const writable = subtractMasked(await resolveWritableZones(agentDir, getSandboxWritablePathSpecs(config)), {
|
|
606
|
+
dirs,
|
|
607
|
+
files,
|
|
608
|
+
})
|
|
594
609
|
// subtractMasked again on the protected set: a protected RO bind renders after
|
|
595
610
|
// the masks (last-op-wins), so an unfiltered protected path nested under a
|
|
596
611
|
// masked dir (e.g. a guest's workspace/ when core.hooksPath=workspace/hooks)
|
|
@@ -599,6 +614,31 @@ async function applyBashSandbox(
|
|
|
599
614
|
const protectedZones = writable.dirs.includes(join(agentDir, '.git'))
|
|
600
615
|
? subtractMasked(await resolveProtectedZones(agentDir), { dirs, files })
|
|
601
616
|
: { dirs: [], files: [] }
|
|
617
|
+
// A recognized standalone `bun add`/`bun install` needs DIRECTORY write at the
|
|
618
|
+
// root (node_modules/ + temp lockfile), which the narrow carve-out model can't
|
|
619
|
+
// grant. Widen to an RW root for that command class only, then re-hide secrets
|
|
620
|
+
// (masks) and re-RO the executable surfaces (resolvePackageInstallZones) on top
|
|
621
|
+
// — subtractMasked drops any protected path a mask already hides so the RW root
|
|
622
|
+
// never re-exposes it. The default jail's `writable`/`protected` are skipped
|
|
623
|
+
// here: writableRoot supersedes them for this command, and the same masks +
|
|
624
|
+
// package-install protected set cover the confidentiality and escalation
|
|
625
|
+
// boundaries.
|
|
626
|
+
const packageInstall = isPackageInstallCommand(command)
|
|
627
|
+
? subtractMaskedProtected(await resolvePackageInstallZones(agentDir), { dirs, files })
|
|
628
|
+
: undefined
|
|
629
|
+
|
|
630
|
+
// Only emit an in-jail symlink for a target that actually survived as a
|
|
631
|
+
// writable dir: a symlink to a target that was dropped (missing, masked for
|
|
632
|
+
// this role, or filtered by the writable-zone guardrails) would dangle onto an
|
|
633
|
+
// EROFS/hidden path. Resolve `from` against the SANDBOX HOME (/tmp), where the
|
|
634
|
+
// per-session tmp dir is bound — NOT the container's real /root, which the jail
|
|
635
|
+
// never sees. The entrypoint shim handles the real-/root symlink for the
|
|
636
|
+
// unsandboxed (trusted/owner) path.
|
|
637
|
+
const writableDirSet = new Set(writable.dirs)
|
|
638
|
+
const sandboxHome = DEFAULT_SANDBOX_ENV.HOME ?? '/tmp'
|
|
639
|
+
const symlinks = resolveSandboxSymlinks(agentDir, config.sandbox.symlinks, sandboxHome).filter((op) =>
|
|
640
|
+
writableDirSet.has(op.target),
|
|
641
|
+
)
|
|
602
642
|
// bwrap does --clearenv, so the overlay must be re-introduced via env.set or
|
|
603
643
|
// it would never reach the sandboxed process (the non-sandboxed spawnHook
|
|
604
644
|
// path does not run when the command is rewritten to a bwrap invocation).
|
|
@@ -608,9 +648,10 @@ async function applyBashSandbox(
|
|
|
608
648
|
{ type: 'ro-bind', source: agentDir, dest: agentDir },
|
|
609
649
|
{ type: 'bind', source: sessionTmp, dest: '/tmp' },
|
|
610
650
|
],
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
651
|
+
...(packageInstall !== undefined
|
|
652
|
+
? { writableRoot: { dir: packageInstall.root }, masks: { dirs, files }, protected: packageInstall.protected }
|
|
653
|
+
: { masks: { dirs, files }, writable, protected: protectedZones }),
|
|
654
|
+
symlinks,
|
|
614
655
|
network: 'inherit',
|
|
615
656
|
cwd: agentDir,
|
|
616
657
|
proc,
|
|
@@ -620,17 +661,25 @@ async function applyBashSandbox(
|
|
|
620
661
|
mutableArgs.command = commandString
|
|
621
662
|
}
|
|
622
663
|
|
|
664
|
+
function subtractMaskedProtected(
|
|
665
|
+
zones: { root: string; protected: { dirs: string[]; files: string[] } },
|
|
666
|
+
masked: { dirs: string[]; files: string[] },
|
|
667
|
+
): { root: string; protected: { dirs: string[]; files: string[] } } {
|
|
668
|
+
const filtered = subtractMasked(zones.protected, masked)
|
|
669
|
+
return { root: zones.root, protected: filtered }
|
|
670
|
+
}
|
|
671
|
+
|
|
623
672
|
// Picks the /proc strategy for a sandboxed bash call. The branch order is:
|
|
624
673
|
// 'real-proc' ONLY when the operator explicitly opted in (sandbox.realProc) AND
|
|
625
674
|
// the kernel permits the mount (canMountRealProc) — it adds PID isolation but
|
|
626
675
|
// needs CAP_SYS_ADMIN (unshare --mount-proc), so it is a deliberate, narrow
|
|
627
676
|
// opt-in; else 'proc-bind' (--ro-bind /proc, NO CAP_SYS_ADMIN) when its userns
|
|
628
|
-
// leak-block is verified safe
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
677
|
+
// leak-block is verified safe; else 'tmpfs'. Because sandbox.realProc DEFAULTS
|
|
678
|
+
// FALSE, the first branch is normally skipped and proc-bind is the de-facto
|
|
679
|
+
// default — which is the point: the common path needs no broad outer capability.
|
|
680
|
+
// 'tmpfs' is the last-resort degraded mode where external packages can't run;
|
|
681
|
+
// reached only when proc-bind is DEFINITIVELY unavailable (a real cross-userns
|
|
682
|
+
// environ leak → fail closed) or its safety stays unverifiable after retries.
|
|
634
683
|
//
|
|
635
684
|
// Read from the boot-time `config` snapshot, NOT live getConfig(): sandbox is
|
|
636
685
|
// restart-required, and the strategy MUST track the boot-time CAP_SYS_ADMIN
|
|
@@ -640,7 +689,16 @@ async function applyBashSandbox(
|
|
|
640
689
|
// container lifetime regardless of how many bash calls hit it.
|
|
641
690
|
async function resolveProcStrategy(): Promise<SandboxProcStrategy> {
|
|
642
691
|
if (config.sandbox.realProc && (await canMountRealProc())) return 'real-proc'
|
|
643
|
-
|
|
692
|
+
// Retry an 'inconclusive' proc-bind probe (transient under load) before
|
|
693
|
+
// degrading — a single such hiccup must not break external-package runs on a
|
|
694
|
+
// capable host. 'unsafe' still fails closed with no retry.
|
|
695
|
+
if (
|
|
696
|
+
await resolveProcBindSafetyWithRetry(
|
|
697
|
+
() => getProcBindSafetyVerdict(),
|
|
698
|
+
(ms) => Bun.sleep(ms),
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
return 'proc-bind'
|
|
644
702
|
// Degraded last resort: no working /proc strategy. External package runners
|
|
645
703
|
// (bunx/bun add/bun run <pkg-bin>) will fail with Bun's opaque "NotDir" because
|
|
646
704
|
// /proc/self/{fd,maps} are absent. Warn once so an operator on such an exotic
|
|
@@ -31,6 +31,16 @@ const GENERIC_SAFE_NOTICE = 'The upstream LLM provider failed. Operators can che
|
|
|
31
31
|
// specific: a miss falls through to GENERIC_SAFE_NOTICE rather than echoing raw
|
|
32
32
|
// text, so adding a new class is opt-in and never widens what we expose.
|
|
33
33
|
const SAFE_CLASSES: ReadonlyArray<{ match: RegExp; safe: string }> = [
|
|
34
|
+
{
|
|
35
|
+
// Auth failure: the provider rejected our credentials (bad/expired/missing
|
|
36
|
+
// API key). Matched first because a 401 body can also mention "account",
|
|
37
|
+
// which would otherwise fall into the billing class below. The safe text
|
|
38
|
+
// names the operator action (check the API key) without echoing the raw
|
|
39
|
+
// error, whose body can carry a Bearer token.
|
|
40
|
+
match:
|
|
41
|
+
/\b(401|unauthori[sz]ed|invalid[_ -]?api[_ -]?key|api key.*(?:invalid|expired|missing)|authentication failed|invalid bearer)\b/i,
|
|
42
|
+
safe: 'The upstream LLM provider rejected the request as unauthorized. Operators should check the provider API key configuration and `typeclaw logs`.',
|
|
43
|
+
},
|
|
34
44
|
{
|
|
35
45
|
match: /\b(usage limit|rate limit|rate.?limited|too many requests|429)\b/i,
|
|
36
46
|
safe: 'The upstream LLM provider is rate-limited (usage limit reached). Try again shortly.',
|
|
@@ -370,6 +370,17 @@ function renderChannelOrigin(
|
|
|
370
370
|
'want to signal acknowledgment explicitly, use `channel_react({ emoji })`',
|
|
371
371
|
'(it reacts, it does not comment) — never a text ack. Reserve `channel_reply`',
|
|
372
372
|
'for the actual substantive answer.',
|
|
373
|
+
'',
|
|
374
|
+
'**A formal review verdict already IS the comment — never post it twice.**',
|
|
375
|
+
'When you submit a PR review (`APPROVE`, `REQUEST_CHANGES`, or `COMMENT`),',
|
|
376
|
+
'the review body renders on the PR as a comment, visually identical to a',
|
|
377
|
+
'plain comment. So put your entire verdict — the "approved", the praise,',
|
|
378
|
+
'the findings — in that review body. Do NOT then post the same (or',
|
|
379
|
+
'paraphrased) text again as a separate `channel_reply` / `gh pr comment`:',
|
|
380
|
+
'that is a visible duplicate, the exact same words landing twice seconds',
|
|
381
|
+
'apart. One verdict, one surface. If you have already submitted the review,',
|
|
382
|
+
'the PR has heard you — `skip_response({ reason: "verdict posted as review" })`',
|
|
383
|
+
'instead of echoing it as a comment.',
|
|
373
384
|
)
|
|
374
385
|
}
|
|
375
386
|
|
|
@@ -442,6 +453,21 @@ function renderChannelOrigin(
|
|
|
442
453
|
' natural terminal action for the turn. Pair it with `skip_response` when',
|
|
443
454
|
' you also want to stay silent this turn.',
|
|
444
455
|
'',
|
|
456
|
+
' **An explicit quiet command is a direct order to call this tool.** When',
|
|
457
|
+
' someone tells you to stop — e.g. "disengage", "be quiet", "stop replying",',
|
|
458
|
+
' "stop", "back off", "stay out of this", "shush", or "조용" / "조용히 해" /',
|
|
459
|
+
' "그만" / "빠져" / "тихо" / "tais-toi" / "cállate" / "ruhig" / "黙って" /',
|
|
460
|
+
' "安静" in any language — you MUST call `channel_disengage` that same turn.',
|
|
461
|
+
' Posting a `channel_reply` like "ok, I\'ll be quiet" is NOT enough on its',
|
|
462
|
+
' own: a reply alone re-grants the very stickiness they asked you to drop,',
|
|
463
|
+
' so without the `channel_disengage` call you stay engaged and keep getting',
|
|
464
|
+
' pulled back in — exactly what they told you to stop. The acknowledgement',
|
|
465
|
+
' does not disengage you; the tool call does. If you ack, ack FIRST with',
|
|
466
|
+
' `channel_reply`, THEN call `channel_disengage`; if you would rather go',
|
|
467
|
+
' quiet without a word, call `channel_disengage` alone (optionally with',
|
|
468
|
+
' `skip_response`). Match intent, not exact words: any clear request to',
|
|
469
|
+
' stop participating counts, whatever the phrasing or language.',
|
|
470
|
+
'',
|
|
445
471
|
'**Every user-facing sentence goes through `channel_reply`.** Narrating in',
|
|
446
472
|
'plain text — "bumping to 16x now", "let me check that" — does NOT reach the',
|
|
447
473
|
'user; it is invisible. If you want the user to see it, it is a',
|
|
@@ -34,15 +34,19 @@ export function createChannelDisengageTool({ router, origin }: CreateChannelDise
|
|
|
34
34
|
name: 'channel_disengage',
|
|
35
35
|
label: 'Channel Disengage',
|
|
36
36
|
description:
|
|
37
|
-
'Stop auto-engaging on follow-up messages in THIS channel/thread.
|
|
38
|
-
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'in a
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
37
|
+
'Stop auto-engaging on follow-up messages in THIS channel/thread. Call this the moment ' +
|
|
38
|
+
'a human or peer bot tells you to stop — "disengage", "be quiet", "stop replying", ' +
|
|
39
|
+
'"stop", "back off", or the same in any language (e.g. "조용", "그만", "黙って", ' +
|
|
40
|
+
'"tais-toi", "cállate"). While engaged you keep replying to a participant\'s next ' +
|
|
41
|
+
'message without an @mention, and that engagement is renewed every time you reply — so ' +
|
|
42
|
+
'in a group you can get stuck answering turn after turn even after someone tells you to ' +
|
|
43
|
+
'stop. A reply like "ok, I will be quiet" does NOT disengage you; it re-grants the ' +
|
|
44
|
+
'stickiness they asked you to drop. Only THIS tool drops it. Also call it when you ' +
|
|
45
|
+
'notice you are in a redundant loop. After disengaging, you only re-engage in this ' +
|
|
46
|
+
'conversation when explicitly addressed again (mention, reply, or DM). This does not ' +
|
|
47
|
+
'send any message and does not affect other channels. If you want to acknowledge first, ' +
|
|
48
|
+
'send the channel_reply BEFORE this call. Pair it with skip_response when you also want ' +
|
|
49
|
+
'to stay silent on the current turn.',
|
|
46
50
|
parameters: Type.Object({}),
|
|
47
51
|
|
|
48
52
|
async execute() {
|
|
@@ -416,8 +416,7 @@ function stripRepoFlagFromCommand(command: string): string {
|
|
|
416
416
|
while (i < command.length) {
|
|
417
417
|
const ch = command[i] as string
|
|
418
418
|
if (ch === '"' || ch === "'") {
|
|
419
|
-
const
|
|
420
|
-
const endQuote = close === -1 ? command.length : close
|
|
419
|
+
const endQuote = findClosingQuote(command, i)
|
|
421
420
|
out += command.slice(i, endQuote + 1)
|
|
422
421
|
i = endQuote + 1
|
|
423
422
|
continue
|
|
@@ -436,6 +435,24 @@ function stripRepoFlagFromCommand(command: string): string {
|
|
|
436
435
|
return out
|
|
437
436
|
}
|
|
438
437
|
|
|
438
|
+
// Index of the quote that closes the one at `open`. Double quotes honor `\"` as
|
|
439
|
+
// a literal (bash processes backslash escapes inside "..."), so a `-R o/r` buried
|
|
440
|
+
// in a `-f body="{\"x\":\"-R o/r\"}"` value is not mistaken for an unquoted flag.
|
|
441
|
+
// Single quotes take everything literally — no escapes — so the next `'` closes.
|
|
442
|
+
// Unterminated quote returns the last index (strip nothing past it).
|
|
443
|
+
function findClosingQuote(command: string, open: number): number {
|
|
444
|
+
const quote = command[open]
|
|
445
|
+
for (let i = open + 1; i < command.length; i++) {
|
|
446
|
+
const ch = command[i]
|
|
447
|
+
if (quote === '"' && ch === '\\') {
|
|
448
|
+
i += 1
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
if (ch === quote) return i
|
|
452
|
+
}
|
|
453
|
+
return command.length - 1
|
|
454
|
+
}
|
|
455
|
+
|
|
439
456
|
// If `command` has an unquoted `-R`/`--repo` repo-flag token starting at `start`
|
|
440
457
|
// (at a word boundary), returns the index just past the flag and its value;
|
|
441
458
|
// otherwise null. Handles `-R o/r`, `--repo o/r`, `-R=o/r`, `--repo=o/r`.
|
|
@@ -447,10 +464,16 @@ function matchRepoFlagAt(command: string, start: number): number | null {
|
|
|
447
464
|
if (!command.startsWith(flag, start)) continue
|
|
448
465
|
let i = start + flag.length
|
|
449
466
|
const sep = command[i]
|
|
467
|
+
// Both value forms validate the slug before stripping: the flag is only a
|
|
468
|
+
// repo flag if its value parses as owner/repo. Without this, the `=` form
|
|
469
|
+
// could strip a non-slug value the detection path would have rejected,
|
|
470
|
+
// diverging detection from rewrite.
|
|
450
471
|
if (sep === '=') {
|
|
451
|
-
i
|
|
452
|
-
|
|
453
|
-
|
|
472
|
+
const valueStart = i + 1
|
|
473
|
+
let j = valueStart
|
|
474
|
+
while (j < command.length && command[j] !== ' ' && command[j] !== '\t') j += 1
|
|
475
|
+
if (!isRepoSlug(command.slice(valueStart, j))) return null
|
|
476
|
+
return j
|
|
454
477
|
}
|
|
455
478
|
if (sep === ' ' || sep === '\t') {
|
|
456
479
|
let j = i
|
|
@@ -493,9 +516,20 @@ function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
|
|
|
493
516
|
const flagRepo = extractRepoFlag(args)
|
|
494
517
|
|
|
495
518
|
if (pathRepos.length > 0) {
|
|
496
|
-
|
|
519
|
+
// Check EVERY repo flag, not just the first: the strip removes all of them,
|
|
520
|
+
// so a single non-redundant flag anywhere is a mint-for-X-hit-Y attempt and
|
|
521
|
+
// must block even when an earlier flag matches the path and would otherwise
|
|
522
|
+
// mask it.
|
|
523
|
+
const flagRepos = extractAllRepoFlags(args)
|
|
524
|
+
if (flagRepos.some((slug) => !pathRepos.includes(slug))) {
|
|
497
525
|
return { kind: 'block', reason: API_REPO_CONFLICT_REASON }
|
|
498
526
|
}
|
|
527
|
+
// Every `-R` here is redundant: it matches the repo already named in the
|
|
528
|
+
// literal path, which is authoritative. `gh api` rejects `-R` outright, so
|
|
529
|
+
// strip the flag rather than let `gh` fail with "unknown shorthand flag".
|
|
530
|
+
// Distinct from graphql (no path, -R IS the hint) — here the path mints the
|
|
531
|
+
// token and the flag is pure noise we remove for syntax.
|
|
532
|
+
if (flagRepos.length > 0) return { kind: 'inject', repoSlugs: pathRepos, stripRepoFlag: true }
|
|
499
533
|
return { kind: 'inject', repoSlugs: pathRepos }
|
|
500
534
|
}
|
|
501
535
|
|
|
@@ -519,6 +553,67 @@ function isGraphqlEndpoint(args: readonly string[]): boolean {
|
|
|
519
553
|
return findApiEndpoint(args) === 'graphql'
|
|
520
554
|
}
|
|
521
555
|
|
|
556
|
+
export type GhAuthEnv = {
|
|
557
|
+
GH_TOKEN?: string | undefined
|
|
558
|
+
GITHUB_TOKEN?: string | undefined
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
|
|
562
|
+
|
|
563
|
+
// The effective token `gh` would use for EACH `gh api` invocation that targets the
|
|
564
|
+
// authenticated-user endpoint (`/user`, `user`, or a `/user/...` descendant). The
|
|
565
|
+
// caller classifies each: an App installation token is not a user identity, so
|
|
566
|
+
// GitHub rejects `/user` for it (token-CLASS mismatch, not an auth failure) — but a
|
|
567
|
+
// PAT IS a user identity and works, so the guard must respect a command-local
|
|
568
|
+
// `GH_TOKEN=…`/`GITHUB_TOKEN=…` override on that invocation, not just process env.
|
|
569
|
+
// Precedence mirrors gh: local GH_TOKEN > process GH_TOKEN > local GITHUB_TOKEN >
|
|
570
|
+
// process GITHUB_TOKEN. Narrow endpoint scope: `/users/{username}`, `/meta`,
|
|
571
|
+
// `/rate_limit` are not user-identity endpoints and never match.
|
|
572
|
+
export function effectiveGhTokensForAuthenticatedUserEndpoint(
|
|
573
|
+
command: string,
|
|
574
|
+
env: GhAuthEnv,
|
|
575
|
+
): Array<string | undefined> {
|
|
576
|
+
const tokens = tokenize(command)
|
|
577
|
+
const ghStarts = findGhInvocations(tokens)
|
|
578
|
+
const result: Array<string | undefined> = []
|
|
579
|
+
for (let i = 0; i < ghStarts.length; i++) {
|
|
580
|
+
const start = ghStarts[i] as number
|
|
581
|
+
const end = ghStarts[i + 1] ?? tokens.length
|
|
582
|
+
if (!isAuthenticatedUserEndpointArgs(tokens.slice(start + 1, end))) continue
|
|
583
|
+
result.push(effectiveGhTokenForInvocation(tokens, start, env))
|
|
584
|
+
}
|
|
585
|
+
return result
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function usesGhApiAuthenticatedUserEndpoint(command: string): boolean {
|
|
589
|
+
return effectiveGhTokensForAuthenticatedUserEndpoint(command, {}).length > 0
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function isAuthenticatedUserEndpointArgs(args: readonly string[]): boolean {
|
|
593
|
+
if (args[0] !== 'api') return false
|
|
594
|
+
const endpoint = findApiEndpoint(args)
|
|
595
|
+
if (endpoint === null) return false
|
|
596
|
+
const normalized = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint
|
|
597
|
+
return normalized === 'user' || normalized.startsWith('user/')
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Walks the contiguous `VAR=val` assignments immediately before `gh` (the same
|
|
601
|
+
// shape findGhInvocations skips) and applies gh's token precedence over env.
|
|
602
|
+
function effectiveGhTokenForInvocation(tokens: readonly string[], ghStart: number, env: GhAuthEnv): string | undefined {
|
|
603
|
+
const local: GhAuthEnv = {}
|
|
604
|
+
for (let i = ghStart - 1; i >= 0 && ENV_ASSIGNMENT_RE.test(tokens[i] as string); i--) {
|
|
605
|
+
const token = tokens[i] as string
|
|
606
|
+
const name = token.slice(0, token.indexOf('='))
|
|
607
|
+
const value = token.slice(token.indexOf('=') + 1)
|
|
608
|
+
// Iterating right-to-left, so only record the first (leftmost wins on dup).
|
|
609
|
+
if (name === 'GH_TOKEN' && local.GH_TOKEN === undefined) local.GH_TOKEN = value
|
|
610
|
+
if (name === 'GITHUB_TOKEN' && local.GITHUB_TOKEN === undefined) local.GITHUB_TOKEN = value
|
|
611
|
+
}
|
|
612
|
+
const ghToken = local.GH_TOKEN ?? env.GH_TOKEN
|
|
613
|
+
if (ghToken !== undefined && ghToken !== '') return ghToken
|
|
614
|
+
return local.GITHUB_TOKEN ?? env.GITHUB_TOKEN
|
|
615
|
+
}
|
|
616
|
+
|
|
522
617
|
function findGhInvocations(tokens: readonly string[]): number[] {
|
|
523
618
|
const starts: number[] = []
|
|
524
619
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -565,6 +660,29 @@ function extractRepoFlag(args: readonly string[]): string | null {
|
|
|
565
660
|
return null
|
|
566
661
|
}
|
|
567
662
|
|
|
663
|
+
// Every valid `-R`/`--repo` slug in `args`, not just the first. The strip removes
|
|
664
|
+
// ALL unquoted repo flags, so the conflict check must see ALL of them: a command
|
|
665
|
+
// like `... -R path/repo -R victim/private` is a mint-for-X-hit-Y attempt where
|
|
666
|
+
// the redundant first flag would otherwise mask the malicious second one.
|
|
667
|
+
function extractAllRepoFlags(args: readonly string[]): string[] {
|
|
668
|
+
const slugs: string[] = []
|
|
669
|
+
for (let i = 0; i < args.length; i++) {
|
|
670
|
+
const arg = args[i]
|
|
671
|
+
if (arg === undefined) continue
|
|
672
|
+
if (arg === '-R' || arg === '--repo') {
|
|
673
|
+
const value = args[i + 1]
|
|
674
|
+
if (value !== undefined && isRepoSlug(value)) slugs.push(value)
|
|
675
|
+
} else if (arg.startsWith('--repo=')) {
|
|
676
|
+
const value = arg.slice('--repo='.length)
|
|
677
|
+
if (isRepoSlug(value)) slugs.push(value)
|
|
678
|
+
} else if (arg.startsWith('-R=')) {
|
|
679
|
+
const value = arg.slice('-R='.length)
|
|
680
|
+
if (isRepoSlug(value)) slugs.push(value)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return slugs
|
|
684
|
+
}
|
|
685
|
+
|
|
568
686
|
// `gh api` flags that consume the FOLLOWING token as their value. The endpoint
|
|
569
687
|
// is the first positional arg that is neither a flag nor a flag's value; only
|
|
570
688
|
// THAT arg is parsed for owner/repo. Scanning every arg (as before) would let a
|