@ngockhoale/ukit 3.4.69 → 3.4.70

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.
@@ -563,13 +563,458 @@ function patchTargets(input) {
563
563
  return { paths, unparseable };
564
564
  }
565
565
 
566
+ // ---- Owner-authorized docs-only git publication (create scope) ------------------
567
+ // The shell-write rule blocks every git commit/push in a planning session. The ONE
568
+ // exception: an explicit OWNER message in the same session authorizes a single,
569
+ // strictly parsed `git commit`/`git push` whose staged files (commit) or every
570
+ // outgoing commit's files (push) are docs-scope. Trust rules:
571
+ // - Only real user turns of the three known host shapes count (mirrors
572
+ // handoff-intent.mjs structure rules). Assistant prose, tool_result round-trips
573
+ // and the command template itself ("NO git commit. NO git push.") never authorize.
574
+ // - The LATEST qualifying owner text decides (a later revocation wins; a later
575
+ // re-grant is honored). Text evidence only RELAXES an already-proven create
576
+ // scope — it never mints ownership, and no TTL marker file participates.
577
+ // Per-op, affirmative, docs-explicit grants. "publish" is deliberately NOT a
578
+ // grant word (ambiguous with npm publish). A grant must name BOTH the exact
579
+ // operation (commit vs push — a commit grant never authorizes a push) AND the
580
+ // docs scope. The newest DECISIVE owner text per op wins: an explicit negative
581
+ // ("do not commit") revokes; questions ("why did push fail?"), history
582
+ // ("the push failed yesterday") and wholly quoted text are NOT orders — they
583
+ // neither grant nor revoke, and the scan continues to older turns.
584
+ // NOTE: JS \b is ASCII-only, so Vietnamese words like "đẩy"/"tạo"/"đừng" can
585
+ // NEVER match \b...\b (đ is not an ASCII word char). Explicit lookarounds keep
586
+ // the same "standalone word" semantics for every script.
587
+ const ownerWord = (core) => new RegExp(`(?<![A-Za-z0-9_])(?:${core})(?![A-Za-z0-9_])`, 'iu');
588
+ const OWNER_PUB_OP = {
589
+ commit: ownerWord('commit'),
590
+ push: ownerWord('push|đẩy'),
591
+ };
592
+ const OWNER_NEG = "(?:no|not|don'?t|do\\s+not|never|without|không|đừng|chưa)";
593
+ const OWNER_PUB_VETO_OP = {
594
+ commit: new RegExp(`(?<![A-Za-z0-9_])${OWNER_NEG}(?![A-Za-z0-9_])[^.\\n]{0,30}?(?<![A-Za-z0-9_])commit(?![A-Za-z0-9_])`, 'iu'),
595
+ push: new RegExp(`(?<![A-Za-z0-9_])${OWNER_NEG}(?![A-Za-z0-9_])[^.\\n]{0,30}?(?<![A-Za-z0-9_])(?:push|đẩy)(?![A-Za-z0-9_])`, 'iu'),
596
+ };
597
+ const OWNER_DOCS_SCOPE = /(?<![A-Za-z0-9_])(?:docs|documentation|tài\s+liệu)(?![A-Za-z0-9_])/iu;
598
+ const OWNER_PUB_CREATE = /(?<![A-Za-z0-9_])(?:create|new|tạo)(?![A-Za-z0-9_])/iu;
599
+ // A grant must be an AFFIRMATIVE ORDER in command position: after optional
600
+ // politeness/filler lead words, a line must OPEN with a publication/create
601
+ // imperative. Merely mentioning the op ("the docs push is pending",
602
+ // "docs commit status", "I mentioned push in docs") is not an order — absence
603
+ // of a negative is never proof of an order.
604
+ const OWNER_LEAD = '(?:(?:please|hãy|vui\\s+lòng|làm\\s+ơn|ok|okay|now|go\\s+ahead(?:\\s+and)?|giúp(?:\\s+tôi)?|then|next|sau\\s+đó|luôn)[,.!]?\\s*)*';
605
+ const OWNER_ORDER_LINE = new RegExp(`^${OWNER_LEAD}(?:commit|push|đẩy|create|tạo)(?=[\\s.,;:!]|$)`, 'iu');
606
+ function opOrderedOnLine(text, op) {
607
+ for (const line of String(text).split('\n')) {
608
+ if (OWNER_ORDER_LINE.test(line) && OWNER_PUB_OP[op].test(line)) return true;
609
+ }
610
+ return false;
611
+ }
612
+ const OWNER_PUB_NON_ORDER = /\?|\b(?:why|how|when|what|where|who|which|did|didn|doesn|was|were|had|has|should|could|would|can|failed|failing|failure|yesterday|earlier|previously|remember)\b/iu;
613
+ // Same bound as the shared session-intent scan window (32 MiB): a bounded tail
614
+ // read, never a whole-file read, and only when a git publication is attempted.
615
+ const OWNER_TAIL_BYTES = 32 * 1024 * 1024;
616
+
617
+ function whollyQuoted(text) {
618
+ const t = String(text).trim();
619
+ if (t.length < 2) return false;
620
+ const pairs = { '"': '"', "'": "'", '`': '`', '“': '”', '‘': '’', '«': '»' };
621
+ return Boolean(pairs[t[0]]) && pairs[t[0]] === t[t.length - 1];
622
+ }
623
+
624
+ // Bounded scan: read only the last OWNER_TAIL_BYTES of the transcript — the
625
+ // same 32 MiB bound as the shared session-intent window — via a positioned
626
+ // read; the whole file is never loaded, and the scan runs only when a git
627
+ // publication is attempted.
628
+ async function readOwnerTail(p) {
629
+ try {
630
+ const fd = await fsp.open(p, 'r');
631
+ try {
632
+ const st = await fd.stat();
633
+ if (!st.isFile() || st.size === 0) return '';
634
+ const len = Math.min(st.size, OWNER_TAIL_BYTES);
635
+ const buf = Buffer.alloc(len);
636
+ const { bytesRead } = await fd.read(buf, 0, len, st.size - len);
637
+ let s = buf.subarray(0, bytesRead).toString('utf8');
638
+ if (len < st.size) { const nl = s.indexOf('\n'); if (nl < 0) return ''; s = s.slice(nl + 1); }
639
+ return s;
640
+ } finally { await fd.close(); }
641
+ } catch { return ''; }
642
+ }
643
+
644
+ function commandTemplateBlock(text) {
645
+ if (/<command-name>|<command-args>/.test(text)) return true;
646
+ const firstLine = String(text).split('\n').find((l) => l.trim()) || '';
647
+ return /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?handoff-(?:create|fullstack|implement|review|status|clear)(?![\w-])/.test(firstLine);
648
+ }
649
+
650
+ // Text blocks of a user turn (owner words), or null when the entry is not a user
651
+ // turn of a known host shape. Tool-output blocks and command-template turns are
652
+ // dropped: they are never the owner's own words.
653
+ function ownerUserBlocks(entry) {
654
+ if (!entry || typeof entry !== 'object') return null;
655
+ // Session bind + sidechain exclusion: a grant is only valid from THIS session's
656
+ // own top-level user turns. Subagent sidechains and foreign-session entries are
657
+ // never owner words, even when they carry a matching payload shape.
658
+ if (entry.isSidechain === true || String(entry.isSidechain) === 'true') return null;
659
+ const sid = entry.sessionId ?? entry.session_id;
660
+ if (sid !== undefined && sid !== null && String(sid) !== '') {
661
+ const cur = payload?.session_id ?? payload?.sessionId;
662
+ if (cur !== undefined && cur !== null && String(cur) !== '' && String(sid) !== String(cur)) return null;
663
+ }
664
+ let content = null;
665
+ if (entry.type === 'user' && entry.message?.role === 'user') content = entry.message.content;
666
+ else if (entry.type === 'message' && entry.message?.role === 'user') content = entry.message.content;
667
+ else if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'user') content = entry.payload.content;
668
+ else return null;
669
+ if (typeof content === 'string') return commandTemplateBlock(content) ? [] : [content];
670
+ if (!Array.isArray(content)) return [];
671
+ const blocks = [];
672
+ for (const b of content) {
673
+ if (!b || typeof b !== 'object') continue;
674
+ if (b.type === 'tool_result' || b.type === 'toolResult' || b.type === 'tool-result') continue;
675
+ if ((b.type === 'text' || b.type === 'input_text') && typeof b.text === 'string' && !commandTemplateBlock(b.text)) blocks.push(b.text);
676
+ }
677
+ return blocks;
678
+ }
679
+
680
+ // -> { commit: {ok, creation}, push: {ok, creation} } — per-op, newest decisive
681
+ // owner text decides independently for each operation.
682
+ let ownerPubMemo = null;
683
+ async function ownerGitPublishAuthorized() {
684
+ if (ownerPubMemo !== null) return ownerPubMemo;
685
+ const res = {
686
+ commit: { ok: false, creation: false },
687
+ push: { ok: false, creation: false },
688
+ };
689
+ const transcriptPath = typeof payload?.transcript_path === 'string' ? payload.transcript_path : '';
690
+ if (transcriptPath) {
691
+ const lines = (await readOwnerTail(transcriptPath)).split('\n');
692
+ const decided = { commit: false, push: false };
693
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
694
+ let entry;
695
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
696
+ const blocks = ownerUserBlocks(entry);
697
+ if (blocks === null) continue;
698
+ for (let bi = blocks.length - 1; bi >= 0; bi -= 1) {
699
+ const b = blocks[bi];
700
+ if (whollyQuoted(b)) continue; // quoted text is never an order (nor a revocation)
701
+ for (const op of ['commit', 'push']) {
702
+ if (decided[op] || !OWNER_PUB_OP[op].test(b)) continue;
703
+ if (OWNER_PUB_VETO_OP[op].test(b)) { decided[op] = true; continue; } // explicit revocation stays denied
704
+ if (!opOrderedOnLine(b, op)) continue; // mention/status/report: not an affirmative order
705
+ if (OWNER_PUB_NON_ORDER.test(b)) continue; // question/history wording: not an order, keep scanning
706
+ if (!OWNER_DOCS_SCOPE.test(b)) continue; // the order must name the docs scope
707
+ decided[op] = true;
708
+ res[op] = { ok: true, creation: OWNER_PUB_CREATE.test(b) };
709
+ }
710
+ }
711
+ if (decided.commit && decided.push) break;
712
+ }
713
+ }
714
+ ownerPubMemo = res;
715
+ return ownerPubMemo;
716
+ }
717
+
718
+ // Strict tokenizer for the exception command: plain argv words only, single- and
719
+ // double-quoted literals allowed; any redirect/meta/expansion/glob character
720
+ // (`; | & < > ( ) $ \` * ? [ ] { } # ~ !`) is outside the grammar and returns null.
721
+ function tokenizeStrict(text) {
722
+ const tokens = [];
723
+ let cur = '';
724
+ let has = false;
725
+ for (let i = 0; i < text.length; i += 1) {
726
+ const ch = text[i];
727
+ if (ch === "'") {
728
+ const end = text.indexOf("'", i + 1);
729
+ if (end === -1) return null;
730
+ cur += text.slice(i + 1, end);
731
+ has = true;
732
+ i = end;
733
+ } else if (ch === '"') {
734
+ let j = i + 1;
735
+ for (;;) {
736
+ if (j >= text.length) return null;
737
+ if (text[j] === '\\' && (text[j + 1] === '"' || text[j + 1] === '\\')) { cur += text[j + 1]; j += 2; continue; }
738
+ if (text[j] === '"') break;
739
+ if (text[j] === '$' || text[j] === '`') return null;
740
+ cur += text[j];
741
+ j += 1;
742
+ }
743
+ has = true;
744
+ i = j;
745
+ } else if (ch === '\\') {
746
+ if (i + 1 >= text.length) return null;
747
+ cur += text[i + 1];
748
+ has = true;
749
+ i += 1;
750
+ } else if (/\s/.test(ch)) {
751
+ if (has) { tokens.push(cur); cur = ''; has = false; }
752
+ } else if (';|&<>()$`*?[]{}#~!'.includes(ch)) {
753
+ return null;
754
+ } else {
755
+ cur += ch;
756
+ has = true;
757
+ }
758
+ }
759
+ if (has) tokens.push(cur);
760
+ return tokens;
761
+ }
762
+
763
+ // -> { op: 'commit' } | { op: 'push', positionals } | null for anything else.
764
+ // Strict: NO git global options at all (`-C`, `-c`, `--git-dir`, `--work-tree`,
765
+ // `--exec`-class redirects cannot be verified, so they are denied, not sanitized),
766
+ // a fixed option allowlist per subcommand, and plain-ref positionals only.
767
+ function parseGitPublication(seg) {
768
+ if (!seg || seg.sep || seg.heredoc) return null;
769
+ const tokens = tokenizeStrict(seg.text);
770
+ if (!tokens || tokens.length < 2 || tokens[0].replace(/^.*\//, '') !== 'git') return null;
771
+ const sub = tokens[1];
772
+ const rest = tokens.slice(2);
773
+ if (sub === 'commit') {
774
+ for (let i = 0; i < rest.length; i += 1) {
775
+ const t = rest[i];
776
+ if (t === '-m' || t === '--message') { i += 1; if (i >= rest.length) return null; continue; }
777
+ if (t.startsWith('--message=')) continue;
778
+ if (/^-m[^-]/.test(t) && t.length > 2) continue;
779
+ if (t === '--allow-empty' || t === '-q' || t === '--quiet') continue;
780
+ return null;
781
+ }
782
+ return { op: 'commit' };
783
+ }
784
+ if (sub !== 'push') return null;
785
+ const benign = new Set(['-q', '--quiet', '-v', '--verbose', '--dry-run', '-u', '--set-upstream', '--progress']);
786
+ const positionals = [];
787
+ for (let i = 0; i < rest.length; i += 1) {
788
+ const t = rest[i];
789
+ if (benign.has(t)) continue;
790
+ if (t.startsWith('-')) return null; // force/mirror/all/tags/delete/… stay denied
791
+ // Plain ref names only: a `src:dst` refspec redirects the destination and is
792
+ // never verified here; `:ref` deletes.
793
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(t)) return null;
794
+ positionals.push(t);
795
+ if (positionals.length > 2) return null;
796
+ }
797
+ return { op: 'push', positionals };
798
+ }
799
+
800
+ function gitRead(...args) {
801
+ try {
802
+ const { execFileSync } = require('child_process');
803
+ return execFileSync('git', ['-C', projectRoot, ...args], {
804
+ encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 4 * 1024 * 1024,
805
+ });
806
+ } catch { return null; }
807
+ }
808
+
809
+ // docs-only means the 'allow-doc' class exactly: a staged/outgoing test file is
810
+ // still outside the publication scope (create sessions write docs; tests ship
811
+ // with the implementation cycle).
812
+ function firstOutOfScopePath(names) {
813
+ for (const n of names) {
814
+ if (String(n).length && classifyScopePath(String(n).replace(/\\/g, '/')) !== 'allow-doc') return n;
815
+ }
816
+ return null;
817
+ }
818
+
819
+ // Regular-file docs only: a symlink (120000) or submodule/gitlink (160000) under
820
+ // docs/ is a non-blueprint payload even though its path looks like docs.
821
+ const REGULAR_FILE_MODES = new Set(['100644', '100755']);
822
+ function nonRegularDocsPayload(entries) {
823
+ for (const e of entries) {
824
+ if (!REGULAR_FILE_MODES.has(e.mode)) return e.path;
825
+ }
826
+ return null;
827
+ }
828
+
829
+ // `git diff --cached --raw -z --no-renames` => ":<srcmode> <dstmode> <sha> <sha> <status>\0<path>\0"
830
+ // --no-renames is mandatory: rename detection would show only the NEW path and
831
+ // hide a source→docs move whose old path is source.
832
+ function parseRawZ(out) {
833
+ const parts = String(out).split('\0');
834
+ const entries = [];
835
+ for (let i = 0; i < parts.length; i += 1) {
836
+ const meta = parts[i];
837
+ if (!meta) continue;
838
+ const m = /^:\d+ (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/.exec(meta);
839
+ if (!m) return null;
840
+ const p = parts[i + 1];
841
+ i += 1;
842
+ if (p === undefined || p === '') return null;
843
+ entries.push({ mode: m[1], status: m[2], path: p });
844
+ }
845
+ return entries;
846
+ }
847
+
848
+ function stagedDocsDenial() {
849
+ const out = gitRead('diff', '--cached', '--raw', '-z', '--no-renames');
850
+ if (out === null) return 'the staged file set could not be read (git diff --cached failed)';
851
+ const entries = parseRawZ(out);
852
+ if (entries === null) return 'the staged file set could not be parsed';
853
+ if (entries.length === 0) return 'nothing is staged — stage the docs files first';
854
+ const bad = firstOutOfScopePath(entries.map((e) => e.path));
855
+ if (bad) return `staged path '${bad}' is not a docs path (owner publication is docs-only)`;
856
+ const nonReg = nonRegularDocsPayload(entries.filter((e) => e.status !== 'D'));
857
+ if (nonReg) return `staged path '${nonReg}' is a symlink or submodule, not a regular docs file`;
858
+ return '';
859
+ }
860
+
861
+ // Push config that can widen or REDIRECT the pushed set/destination. The local
862
+ // upstream alone is NOT proof of the destination: remote.<r>.push refspecs,
863
+ // remote.<r>.mirror, push.followTags, branch.<b>.pushRemote and
864
+ // remote.pushDefault can all change what a plain `git push` actually sends.
865
+ function pushConfigDenial(remote, branch) {
866
+ const truthy = (v) => v !== null && !['false', '0', 'no', 'off'].includes(String(v).trim().toLowerCase());
867
+ const wide = [];
868
+ if (truthy(gitRead('config', '--get-all', 'push.followTags'))) wide.push('push.followTags widens the pushed set with tags');
869
+ if (truthy(gitRead('config', '--get-all', `remote.${remote}.mirror`))) wide.push(`remote.${remote}.mirror redirects/widens the push`);
870
+ if ((gitRead('config', '--get-all', 'remote.pushDefault') || '').trim()) wide.push('remote.pushDefault redirects the push destination');
871
+ if (branch && (gitRead('config', '--get-all', `branch.${branch}.pushRemote`) || '').trim()) wide.push(`branch.${branch}.pushRemote redirects the push destination`);
872
+ if ((gitRead('config', '--get-all', `remote.${remote}.push`) || '').trim()) wide.push(`remote.${remote}.push refspec config redirects the pushed set`);
873
+ const pdRaw = (gitRead('config', '--get-all', 'push.default') || '').trim();
874
+ if (pdRaw) {
875
+ const vals = pdRaw.split('\n').map((s) => s.trim()).filter(Boolean);
876
+ if (vals.some((v) => !['simple', 'current', 'upstream', 'tracking', 'nothing'].includes(v))) {
877
+ wide.push(`push.default=${vals.join(',')} widens the pushed set beyond the verified ref`);
878
+ }
879
+ }
880
+ return wide.length ? `push config cannot be verified here: ${wide.join('; ')}` : '';
881
+ }
882
+
883
+ // Push verifies the OUTGOING COMMITS, not the net tree diff: a commit that added
884
+ // source and a later commit that reverted it hide each other in `diff A..B` but
885
+ // both go to the remote. Every outgoing commit's changed files must be docs-scope,
886
+ // every git read is failclosed, and the destination is pinned to the verified
887
+ // upstream (or, for a brand-new remote branch, to an explicit owner "create"
888
+ // order with a docs-only root off the remote default head).
889
+ function pushOutgoingDocsDenial(positionals, grant) {
890
+ const pos = Array.isArray(positionals) ? positionals : [];
891
+ const head = (gitRead('rev-parse', '--abbrev-ref', 'HEAD') || '').trim();
892
+ if (!head) return 'the current branch could not be read';
893
+ const noUpstreamMsg = 'the outgoing set cannot be verified — a first push of a new branch needs an explicit "git push -u <remote> <branch>" plus an owner order that says create/new; otherwise push once from an implementation session or configure upstream first';
894
+ let remote; let tracking; let creating = false;
895
+ if (pos.length === 0) {
896
+ tracking = (gitRead('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}') || '').trim();
897
+ if (!tracking) return noUpstreamMsg;
898
+ remote = tracking.split('/')[0];
899
+ } else if (pos.length === 1) {
900
+ remote = pos[0];
901
+ tracking = `${remote}/${head}`;
902
+ } else {
903
+ remote = pos[0];
904
+ const br = pos[1].replace(/^refs\/heads\//, '');
905
+ if (br !== head) return `the pushed branch '${br}' is not the current branch ('${head}') — destination refspecs are not verified here`;
906
+ tracking = `${remote}/${br}`;
907
+ }
908
+ const trackingExists = gitRead('rev-parse', '--verify', '--quiet', `${tracking}^{commit}`) !== null;
909
+ if (!trackingExists && !creating) {
910
+ if (pos.length === 0) return noUpstreamMsg;
911
+ const cfgDeny = pushConfigDenial(remote, head);
912
+ if (cfgDeny) return cfgDeny;
913
+ if (!grant || !grant.creation) return `creating remote branch '${tracking.split('/')[1]}' needs an owner order that explicitly says create/new (e.g. "create branch and push the docs") — a plain push order cannot bound the outgoing set here`;
914
+ creating = true;
915
+ }
916
+ if (creating) {
917
+ // Bounded creation against the LIVE remote baseline, never a cached ref.
918
+ // ls-remote failure (null) denies; an empty listing is a real empty remote
919
+ // only when the command itself succeeded (exit 0 with no output).
920
+ // NOT --heads: that filter drops the HEAD/symref line the default-head pin needs.
921
+ const live = gitRead('ls-remote', '--symref', remote);
922
+ if (live === null) return `the live remote listing for '${remote}' could not be read — the creation baseline cannot be proven, so the push is denied`;
923
+ const heads = live.split('\n').filter((l) => /^[0-9a-f]{40}\trefs\/heads\//.test(l));
924
+ const headSymref = live.split('\n').find((l) => /^ref: refs\/heads\/\S+\tHEAD$/.test(l));
925
+ if (heads.some((l) => l.endsWith(`\trefs/heads/${head}`))) {
926
+ return `remote branch '${head}' already exists — a create order cannot re-create or bypass pinning for an existing branch`;
927
+ }
928
+ let base = null;
929
+ if (heads.length > 0) {
930
+ if (!headSymref) return `the live remote default head of '${remote}' is unknown — the creation outgoing set cannot be bounded [live=${JSON.stringify(live.slice(0, 200))}]`;
931
+ const defBranch = headSymref.replace(/^ref: refs\/heads\//, '').replace(/\tHEAD$/, '');
932
+ const defLine = heads.find((l) => l.endsWith(`\trefs/heads/${defBranch}`));
933
+ if (!defLine) return `the live remote default head '${defBranch}' has no commit — the creation outgoing set cannot be bounded`;
934
+ base = defLine.split('\t')[0];
935
+ if (gitRead('cat-file', '-e', `${base}^{commit}`) === null) {
936
+ return `the live remote default head ${base.slice(0, 10)} is not present locally — the creation outgoing set cannot be bounded`;
937
+ }
938
+ }
939
+ return verifyOutgoing(base, head, `creating ${tracking}`);
940
+ }
941
+ const cfgDeny = pushConfigDenial(remote, head);
942
+ if (cfgDeny) return cfgDeny;
943
+ const upstream = (gitRead('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}') || '').trim();
944
+ if (upstream !== tracking) return `the push target '${tracking}' does not match this branch's upstream${upstream ? ` '${upstream}'` : ' (none set)'} — the actual destination cannot be pinned, so it is denied`;
945
+ if ((gitRead('rev-list', '--count', `HEAD..${tracking}`) || '1').trim() !== '0') {
946
+ return `the local branch has diverged from ${tracking} — the push would be non-fast-forward`;
947
+ }
948
+ // Pin the ACTUAL destination: a stale remote-tracking ref (e.g. the remote was
949
+ // rewound after the local ref was recorded) can make a source-carrying push
950
+ // look like a no-op or docs-only. The local ref must equal the remote's real
951
+ // head right now; an unreadable answer denies.
952
+ const localTip = (gitRead('rev-parse', '--verify', '--quiet', `${tracking}^{commit}`) || '').trim();
953
+ const remoteTip = (gitRead('ls-remote', remote, `refs/heads/${head}`) || '').split('\t')[0].trim();
954
+ if (!localTip || !remoteTip || localTip !== remoteTip) {
955
+ return `the remote-tracking ref '${tracking}' does not match the remote's actual head (refs/heads/${head}) — a stale ref cannot bound the destination, so the push is denied`;
956
+ }
957
+ return verifyOutgoing(tracking, head, '');
958
+ }
959
+
960
+ // Lists rev..head and checks every commit is docs-scope. Every git read is
961
+ // failclosed: a failed rev-list or diff-tree DENIES (never silently allows).
962
+ function verifyOutgoing(base, localRef, what) {
963
+ const revsOut = base === null ? gitRead('rev-list', localRef) : gitRead('rev-list', `${base}..${localRef}`);
964
+ if (revsOut === null) return `the outgoing commits could not be listed (git rev-list failed${what ? ` for ${what}` : ''}) — denied`;
965
+ const revs = revsOut.split('\n').map((s) => s.trim()).filter(Boolean);
966
+ if (revs.length === 0) return ''; // nothing outgoing: the push is a no-op
967
+ if (revs.length > 400) return `${revs.length} outgoing commits exceed the verification bound`;
968
+ for (const sha of revs) {
969
+ const raw = gitRead('diff-tree', '--root', '-r', '-m', '--no-renames', '--no-commit-id', '-z', sha);
970
+ if (raw === null) return `outgoing commit ${sha.slice(0, 10)} could not be inspected (git diff-tree failed) — denied`;
971
+ const entries = parseRawZ(raw);
972
+ if (entries === null || entries.length === 0) return `outgoing commit ${sha.slice(0, 10)} could not be parsed — denied`;
973
+ const bad = firstOutOfScopePath(entries.map((e) => e.path));
974
+ if (bad) return `outgoing commit ${sha.slice(0, 10)} touches '${bad}', which is not a docs path (publication is docs-only)`;
975
+ const nonReg = nonRegularDocsPayload(entries.filter((e) => e.status !== 'D'));
976
+ if (nonReg) return `outgoing commit ${sha.slice(0, 10)} adds '${nonReg}' as a symlink or submodule, not a regular docs file`;
977
+ }
978
+ return '';
979
+ }
980
+
981
+ async function ownerGitPublishDenial(pub) {
982
+ const grants = await ownerGitPublishAuthorized();
983
+ const grant = grants[pub.op];
984
+ if (!grant || !grant.ok) {
985
+ return `the owner has not explicitly ordered a docs-only ${pub.op} in this session — an affirmative owner message naming "${pub.op}" and the docs scope is required; questions, history, quoted text, assistant text, tool output and this command's own "NO git commit" text never authorize`;
986
+ }
987
+ return pub.op === 'commit' ? stagedDocsDenial() : pushOutgoingDocsDenial(pub.positionals, grant);
988
+ }
989
+
566
990
  // createScopeVerdict({toolName, filePath, filePaths, command, projectDir, intent})
567
991
  // -> {block:boolean, cls:string, reason?:string}
568
992
  async function createScopeVerdict({ toolName: tool, filePath, filePaths, command, projectDir, intent, patchUnparseable }) {
569
993
  if (intent !== 'create') return { block: false, cls: 'not-create' };
570
994
  const alt = 'Write docs/tests only, or run /ukit:handoff-fullstack (or start a new session) to change source.';
571
995
  if (tool === 'Bash') {
572
- if (!shellWriteHit(String(command || ''))) return { block: false, cls: 'shell-read' };
996
+ const cmdText = String(command || '');
997
+ if (!shellWriteHit(cmdText)) return { block: false, cls: 'shell-read' };
998
+ // The owner-authorized publication exception is reachable only for a command
999
+ // that parses as ONE strictly-validated git commit/push; every other shell
1000
+ // write keeps the conservative block.
1001
+ let pub = null;
1002
+ if (/(?:^|[;&|\n\s])git\s+(?:commit|push)\b/.test(cmdText)) {
1003
+ const segs = splitShellSegments(cmdText);
1004
+ if (segs.length === 1) pub = parseGitPublication(segs[0]);
1005
+ }
1006
+ if (pub) {
1007
+ const why = await ownerGitPublishDenial(pub);
1008
+ if (!why) {
1009
+ await auditCreateDecision({ phase: intent, source: 'owner-turn', decision: 'allow', cls: 'owner-git-publish', op: pub.op });
1010
+ return { block: false, cls: 'owner-git-publish' };
1011
+ }
1012
+ return {
1013
+ block: true,
1014
+ cls: 'shell-write',
1015
+ reason: `handoff-create is planning-only: this git ${pub.op} was not allowed — ${why}. The exception needs an explicit owner message in this session plus a pure "git commit"/"git push" of docs-only files. Use Write/Edit for allowed docs/tests paths, or run /ukit:handoff-fullstack (or start a new session) to change source.`,
1016
+ };
1017
+ }
573
1018
  return {
574
1019
  block: true,
575
1020
  cls: 'shell-write',
@@ -738,6 +1183,19 @@ async function createScopeGate() {
738
1183
  }
739
1184
 
740
1185
  if (isAgent) {
1186
+ // Planning dispatches run in THIS checkout: any isolation request (worktree/remote,
1187
+ // native `isolation` string or `isolated: true`) is refused. Explicit none/false is fine.
1188
+ const isoTasks = Array.isArray(input?.tasks) && input.tasks.length ? input.tasks : [input];
1189
+ const isolated = isoTasks.some((t) => {
1190
+ if (!t || typeof t !== 'object') return false;
1191
+ const iso = t.isolation;
1192
+ const isoOn = Boolean(iso) && !['none', 'false', 'off'].includes(String(iso).trim().toLowerCase());
1193
+ return isoOn || t.isolated === true || String(t.isolated ?? '').toLowerCase() === 'true';
1194
+ });
1195
+ if (isolated) {
1196
+ await auditCreateDecision({ phase: intent, source, decision: 'deny', cls: 'agent-isolation' });
1197
+ block('handoff-create is planning-only: planning dispatches run in this checkout and must omit the isolation field (no worktree/remote). Re-dispatch the same agent without `isolation`; do not change the agent.');
1198
+ }
741
1199
  const type = delegatedAgentTypes(input).find((t) => !CREATE_AGENT_ALLOW.has(t));
742
1200
  if (type !== undefined) {
743
1201
  await auditCreateDecision({ phase: intent, source, decision: 'deny', cls: 'agent-delegation', agent: type || 'unspecified' });