Compare commits

...

17 Commits

Author SHA1 Message Date
6a9c18f5a5 fix(taiga-auto-sync): match real Taiga MCP tool names and drop impossible owner/sprint checks
The write-guard's matcher regex never matched the actual tool names
(createUserStory/createTask/createIssue, not user_story_create etc.), so
the metadata guard has never actually fired. It also checked for
assigned_to/milestone fields that don't exist on any create tool's
schema, which would have denied every create once the matcher was fixed.
Guard now validates only the tag contract (area/source/target), covers
the batch-create tools per item, and SKILL.md documents the real
follow-up calls (assignIssue/assignUserStoryToSprint/addIssueToSprint)
needed to set owner and sprint after creation.
2026-09-14 00:31:53 +07:00
98dca237f4 Add infra-ops-toolkit plugin: topology diagram, html-to-pdf, teleport onboarding skills
Distills patterns from the Teleport access topology work: verified-data diagram
building, as-displayed HTML-to-PDF export via headless Chromium, and
container-scoped/host/database onboarding into an existing Teleport cluster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3ZTfgQrkR3q8DSEoZAmvs
2026-09-08 16:11:36 +07:00
f2b97d4820 fix: restore valid marketplace.json (openbao-session 0.1.3)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:37:15 +07:00
6ff604729e docs: sync openbao-session marketplace version to 0.1.3
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:37:02 +07:00
ed7c1b0a0f fix(openbao-session): always pass -ttl so session tokens stay short-lived
Without an explicit -ttl the role path could mint ~32-day session tokens.
Pass SESSION_TTL in both role and policy modes. Bump 0.1.3.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:37:00 +07:00
ddbdf468e3 docs(openbao-session): creator policy needs auth/token/create/* for role-based minting
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:35:31 +07:00
773d7d50bd docs: sync openbao-session marketplace version to 0.1.2
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:28:48 +07:00
0f77ad534e fix(openbao-session): use a token role for entity-alias minting
entity-alias only works with -role; add OPENBAO_SESSION_ROLE so session tokens
can carry the user entity (and read humans/self) via the openbao-session role.
Bump 0.1.2.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:28:38 +07:00
5699bf7259 docs: sync openbao-session marketplace version to 0.1.1
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:19:17 +07:00
6877bc7202 feat(openbao-session): tie session token to the user entity
Mint the session token with -entity-alias from OPENBAO_SESSION_ENTITY so
{{identity.entity.id}} resolves and the session can read humans/self (read-only)
in addition to shared/*. Document the claude-session policy with humans/self.
Bump 0.1.1.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 11:19:00 +07:00
6ad7202fdf feat(shared-vault-sync): add commit-driven auto-push script
push-shared-vault.sh pushes the shared vault only when the worktree is clean
and the local branch is ahead of origin (fetch first); never rebases,
force-pushes, or resolves conflicts. Ships with launchd setup docs. Bump 0.1.2.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 10:33:49 +07:00
1bd000e595 feat(shared-vault-sync): sync the nested devops-shared-vault subrepo
Detection order is now SHARED_VAULT_DIR, then $PWD, then $PWD/Areas/DevOps
(the nested shared subrepo inside the personal vault). Bump to 0.1.1.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 10:15:35 +07:00
912e85fbf7 feat: sync shared vault at session start 2026-09-01 09:40:46 +07:00
5166f12775 feat: add openbao-session plugin for interactive agents
SessionStart hook that provisions a short-lived, policy-scoped OpenBao token
for each agent session. For Claude Code it writes export lines into
$CLAUDE_ENV_FILE; for hosts that cannot persist env from hooks (Codex) it
writes a mode-600 session env file and points the agent to it via context.
Failure degrades to a benign JSON so sessions still start. Registered in both
Claude and Codex marketplace manifests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 02:16:01 +07:00
6693213b02 Write Taiga updates in Indonesian 2026-08-29 20:14:23 +07:00
0dbccee979 Activate Taiga tracking only for infra ops work 2026-08-29 20:04:47 +07:00
b56f7def02 Enforce Taiga active item governance 2026-08-29 19:35:45 +07:00
25 changed files with 985 additions and 17 deletions

View File

@@ -7,6 +7,24 @@
"source": "./plugins/taiga-auto-sync",
"description": "Injects IT Infra & Ops Taiga tracking rules on every prompt.",
"version": "0.1.0"
},
{
"name": "openbao-session",
"source": "./plugins/openbao-session",
"description": "Provisions a short-lived OpenBao session token for interactive agents.",
"version": "0.1.3"
},
{
"name": "shared-vault-sync",
"source": "./plugins/shared-vault-sync",
"description": "Safely pulls the shared vault at session start.",
"version": "0.1.2"
},
{
"name": "infra-ops-toolkit",
"source": "./plugins/infra-ops-toolkit",
"description": "Topology diagram building, as-is HTML→PDF export, and Teleport resource onboarding skills.",
"version": "0.1.0"
}
]
}

View File

@@ -1,12 +1,56 @@
{
"name": "infra-plugins",
"interface": { "displayName": "Infra Plugins" },
"interface": {
"displayName": "Infra Plugins"
},
"plugins": [
{
"name": "taiga-auto-sync",
"source": { "source": "local", "path": "./plugins/taiga-auto-sync" },
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
"source": {
"source": "local",
"path": "./plugins/taiga-auto-sync"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
},
{
"name": "openbao-session",
"source": {
"source": "local",
"path": "./plugins/openbao-session"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Security"
},
{
"name": "shared-vault-sync",
"source": {
"source": "local",
"path": "./plugins/shared-vault-sync"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
},
{
"name": "infra-ops-toolkit",
"source": {
"source": "local",
"path": "./plugins/infra-ops-toolkit"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "DevOps"
}
]
}

View File

@@ -0,0 +1,17 @@
{
"name": "infra-ops-toolkit",
"version": "0.1.0",
"description": "Skills for building the infra access topology diagram, exporting any HTML page to PDF as-displayed, and onboarding new resources into Teleport.",
"author": { "name": "Senkensha" },
"interface": {
"displayName": "Infra Ops Toolkit",
"shortDescription": "Topology diagram, as-is HTML→PDF export, and Teleport onboarding skills.",
"longDescription": "Three skills distilled from real MBU Group infrastructure work: (1) topology-diagram — build/update the light-theme HTML+SVG access topology and its drawio mirror from live-verified doctl/aws/tsh data; (2) html-to-pdf — export any HTML page to a PDF that matches the live browser view exactly, using headless Chromium in screen mode with a content-sized page instead of the browser's fixed-paper print dialog; (3) teleport-onboard — onboard a new VPS, EC2 host, container-scoped app login, or database into a Teleport cluster following the team's established least-privilege patterns.",
"developerName": "Senkensha",
"category": "DevOps",
"capabilities": [
"Skills"
],
"defaultPrompt": "Help me update the infra topology diagram."
}
}

View File

@@ -0,0 +1,6 @@
{
"name": "infra-ops-toolkit",
"version": "0.1.0+codex.20260908000000",
"description": "Skills for building the infra access topology diagram, exporting any HTML page to PDF as-displayed, and onboarding new resources into Teleport.",
"author": { "name": "Senkensha" }
}

View File

@@ -0,0 +1,11 @@
# infra-ops-toolkit
Three skills distilled from real infrastructure work, for anyone building infra documentation or onboarding resources into Teleport:
- **topology-diagram** — build/update a light-theme HTML+SVG access topology diagram (mirrored into a `.drawio.xml`), always re-verifying every IP/spec/capacity against live sources (`doctl`, `aws`, `tsh`) instead of trusting the diagram's own prior state.
- **html-to-pdf** — export any HTML page to a PDF that matches its live browser rendering exactly, using headless Chromium in screen mode with a content-sized page, instead of fighting a browser's fixed-paper Print dialog.
- **teleport-onboard** — onboard a new VPS, EC2 host, container-scoped app login, or database into a Teleport cluster by discovering and mirroring the team's existing patterns (forced-login-shell wrapper scripts, scoped sudoers, `tctl`-created roles) rather than inventing a new access model.
## Install
Install `infra-ops-toolkit` from the `infra-plugins` marketplace. Skills activate automatically when a prompt matches their description, or invoke directly: `/topology-diagram`, `/html-to-pdf`, `/teleport-onboard`.

View File

@@ -0,0 +1,75 @@
---
name: html-to-pdf
description: Export a local HTML file to a PDF that looks exactly like the live browser rendering — no shrink-to-fit distortion, no forced page splits, no vanished sticky/grid elements. Use when the user says a browser's own Print-to-PDF/Print dialog produced a broken, tiny, or wrongly-paginated result and they want the PDF "as-is / seperti yang tampil di browser" instead of a print-reflowed version.
---
# HTML → PDF, exactly as displayed
## Why the browser's own Print dialog fails for this
A system print dialog always targets a **fixed paper size** (Letter/A4). For any page wider than ~800px of real content — a multi-column CSS Grid layout, a wide diagram — the browser either shrinks the *entire* page to fit one sheet (readable content becomes a postage stamp) or splits it across pages in ways that don't match what's on screen. Two extra failure modes compound this:
- `position: sticky` elements often just vanish or mis-render under the print media type.
- If the page has its own `@media print` rules (e.g. for a *deliberately* paginated print layout), those override the normal on-screen styling — which is the opposite of what "as-is" means here.
Neither "no print CSS at all" nor "add print CSS to force page-breaks" gives you the *live browser view* as a PDF. The actual fix is to render with a **page size equal to the content's own dimensions**, which no manual print dialog lets you set.
## The fix: headless Chromium in screen mode, content-sized page
```bash
mkdir -p /tmp/pdfgen && cd /tmp/pdfgen
npm init -y >/dev/null 2>&1
npm install puppeteer # bundles a compatible Chromium — no separate browser install needed
```
```js
// render.js
const puppeteer = require('puppeteer');
const path = require('path');
(async () => {
const srcPath = '/absolute/path/to/source.html';
const outPath = '/absolute/path/to/output.pdf';
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Match the viewport width the page's CSS was actually designed around
// (check its max-width / .page container, not an arbitrary guess).
await page.setViewport({ width: 1940, height: 1200, deviceScaleFactor: 2 });
await page.goto('file://' + srcPath, { waitUntil: 'networkidle0' });
// The critical line: force normal on-screen styles, ignoring any
// @media print rules the page might define for its own purposes.
await page.emulateMediaType('screen');
// Measure the real rendered size so the PDF page is exactly that size —
// one continuous page, nothing scaled, nothing split.
const { width, height } = await page.evaluate(() => {
const el = document.querySelector('.page') || document.body;
const rect = el.getBoundingClientRect();
return { width: Math.ceil(rect.width), height: Math.ceil(document.documentElement.scrollHeight) };
});
await page.pdf({
path: outPath,
width: `${width}px`,
height: `${height}px`,
printBackground: true, // otherwise CSS background colors silently disappear
margin: { top: 0, right: 0, bottom: 0, left: 0 },
});
await browser.close();
console.log('Saved:', outPath, width, 'x', height);
})().catch(err => { console.error(err); process.exit(1); });
```
```bash
node render.js
```
## Notes
- `printBackground: true` is easy to forget and its absence is easy to misdiagnose as "the PDF looks washed out" rather than "backgrounds are just missing."
- If the page measures its own scroll height *before* web fonts finish loading, dimensions can be slightly off — `waitUntil: 'networkidle0'` on `goto` covers most cases; add an explicit `page.evaluateHandle('document.fonts.ready')` await first if fonts still look unloaded in the output.
- This same technique works for any "I want the PDF to look like the page, not like a printout" request — it's not specific to any one diagram or document.
- Verify the result before calling it done: read the generated PDF back (most tools can render/preview a PDF's first page) and compare it against the live browser view side by side, don't just trust that `page.pdf()` succeeded without error.

View File

@@ -0,0 +1,144 @@
---
name: teleport-onboard
description: Onboard a new resource into a Teleport cluster — a VPS/droplet, an EC2 (or any fresh VM) host, a container-scoped least-privilege app login on a multi-app host, or a database (db_service) — following an existing team's established patterns rather than inventing a new access model. Use when asked to register, join, or onboard something into Teleport, add scoped/restricted access to a specific app or container, or when Teleport resources (nodes/databases/roles) don't match what's actually running.
---
# Teleport resource onboarding
## First: discover the existing pattern, don't assume one
Before creating anything, SSH into a host that's already onboarded the way you intend to onboard the new one, and read what's actually there:
```bash
cat /etc/passwd # look for non-standard login shells — a sign of scoped access
sudo cat /etc/sudoers.d/<candidate-user> # the exact commands that login is allowed to run
sudo cat /etc/teleport.yaml # or find it: /opt/teleport/config/, /etc/teleport/ — it varies
ps aux | grep teleport # more than one teleport process on a host is a real bug (see gotcha below)
```
Do not invent a new access model if one already exists — mirror it exactly, including its exact `sudoers` syntax, its wrapper-script conventions, and its Teleport role shape.
**Where `tctl` actually runs**: the cluster's auth server is often just one specific host's Docker container (look for an image like `teleport-distroless` in `docker ps`), not a separate admin machine. `sudo docker exec teleport tctl ...` gives full cluster admin without a separate tctl binary or identity file — check for this before assuming you need new credentials.
## Pattern A — container-scoped forced-login-shell (multi-app host, least privilege per app)
Use when one host runs several unrelated apps as containers and different people need access to *only their own* app's container, never the host shell or a sibling's container.
1. **Wrapper script** `/usr/local/bin/enter-<app>.sh` (root:root, `0755`):
```bash
#!/bin/bash
set -euo pipefail
# Forced login shell — must never fall back to a real host shell.
if [ ! -t 0 ]; then echo "Interactive TTY required." >&2; exit 1; fi
echo "=== <app> - container access ==="
echo "1) <container-a>"
echo "2) <container-b>"
read -rp "Pilih [1-2]: " choice
case "$choice" in
1) exec sudo /usr/bin/docker exec -it <container-a> sh ;;
2) exec sudo /usr/bin/docker exec -it <container-b> sh ;;
*) echo "Pilihan tidak valid."; exit 1 ;;
esac
```
One numbered menu entry per container this login may reach. Never a generic `docker exec -it "$1"` — the container name must be hard-coded per case, or `sudoers` command-matching below becomes meaningless.
2. **OS user**: `useradd -m -s /usr/local/bin/enter-<app>.sh <login>` — mind the **32-character Linux username limit** (`useradd: invalid user name` is exactly that error); shorten rather than truncate blindly so the name stays meaningful.
3. **Sudoers**, one line per allowed container, no wildcards:
```
<login> ALL=(root) NOPASSWD: /usr/bin/docker exec -it <container-a> sh
<login> ALL=(root) NOPASSWD: /usr/bin/docker exec -it <container-b> sh
```
Write to `/etc/sudoers.d/<login>`, `chmod 440`, and **always** `visudo -c -f <file>` before trusting it — a syntax error here can be silent until someone tries to log in, or can break sudo more broadly if written wrong.
4. **Teleport role** (via `tctl create -f`, from wherever the auth server actually runs):
```yaml
kind: role
version: v7
metadata:
name: access-<app>
spec:
allow:
logins: ["<login>"]
node_labels: {"*": "*"} # or {name: ["<Exact Droplet Display Name>"]} to scope to one specific host
```
Check how existing analogous roles are assigned (`tctl get users --format=json`) — new roles usually get created *unassigned*, with assignment to a real person happening later as a separate, explicit step. Don't assume you should assign it to anyone.
5. **Verify** — don't just trust that the files look right:
```bash
sudo -l -U <login> # must show exactly the intended docker exec commands, nothing else
```
Also spot-check that `sh` actually exists in each target container image (`docker exec <container> sh -c 'echo ok'`) — a container built on a shell-less base image will make the wrapper script fail at the worst time.
## Pattern B — single-app host (simpler, no wrapper needed)
When a host only runs one app's containers, there's nothing to scope *within* the host — a plain restricted login is enough:
```bash
useradd -m -s /bin/bash -G docker,adm <login> # docker group for the app's containers, adm for logs
```
No sudoers file, no wrapper script. Confirm this is really the pattern in use (Pattern A hosts and Pattern B hosts can coexist across a fleet) before picking one over the other.
## Pattern C — onboarding a brand-new host (EC2 or any fresh VM)
1. Bootstrap plain SSH access first — the host isn't in Teleport yet, so Teleport can't get you in. Try available keys, confirm passwordless sudo:
```bash
ssh -i ~/.ssh/<key> <user>@<host-ip> "sudo -n true && echo ok"
```
2. Install the Teleport agent at the **same major.minor version as the cluster** (check with `tctl status` first):
```bash
curl https://apt.releases.teleport.dev/gpg -o /tmp/teleport-pubkey.asc
sudo tee /etc/apt/keyrings/teleport-archive-keyring.asc < /tmp/teleport-pubkey.asc
echo "deb [signed-by=/etc/apt/keyrings/teleport-archive-keyring.asc] https://apt.releases.teleport.dev/ubuntu $(. /etc/os-release; echo $VERSION_CODENAME) stable/v<MAJOR>" | sudo tee /etc/apt/sources.list.d/teleport.list
sudo apt-get update -qq && sudo apt-get install -y teleport
```
3. Generate a short-lived join token from the auth server:
```bash
tctl tokens add --type=node,db --ttl=15m --format=json
```
4. Write `/etc/teleport.yaml` on the new host:
```yaml
version: v3
teleport:
nodename: <display-name>
data_dir: /var/lib/teleport
proxy_server: <proxy>:443
join_params: { token_name: "<token>", method: token }
auth_service: { enabled: "no" }
proxy_service: { enabled: "no" }
ssh_service:
enabled: "yes"
labels: { name: "<Display Name>", tier: infra, provider: <aws|...> }
```
Add a `db_service` block too if this host also fronts a database (see Pattern D).
5. `sudo systemctl enable teleport && sudo systemctl start teleport`, then confirm from your own client — `tsh ls` should show the new node within seconds.
6. Also create the broad host-level login (`devops`/whatever the fleet convention is, with passwordless sudo) on the new host so it inherits the same team-wide access as every other host — a new scoped role is **additive**, never a replacement for existing broad access. Double-check the login actually exists on the OS (a cloud image may only ship `ubuntu`, not `devops`).
## Pattern D — registering a database (`db_service`)
```yaml
db_service:
enabled: "yes"
databases:
- name: <name>
protocol: postgres # or mysql
uri: <host>:<port>
tls:
mode: verify-full # has a real, verifiable TLS cert (managed/cloud DB)
ca_cert_file: /etc/teleport/db-ca/<name>.crt
# OR, for a self-hosted DB with no real cert (a plain dev postgres container, etc):
# mode: insecure
static_labels: { env: prod|dev, tier: infra }
```
For AWS RDS, download the provider's CA bundle rather than guessing at `insecure` mode:
```bash
curl -s https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -o /etc/teleport/db-ca/aws-rds-global-bundle.pem
```
Restart the agent (`systemctl restart teleport`) after any `teleport.yaml` edit. **If you're SSHed in over Teleport itself, this drops your own session** — that's expected, not a failure; reconnect and check `systemctl is-active teleport` + `journalctl -u teleport -n 40 --no-pager` for real errors.
## Known gotcha: orphaned teleport processes shadow new resources
A host can end up running **two teleport processes** — the current systemd-managed one, and an older one left over from a migration (started manually or via an old init method, its config file possibly already deleted, invisible to `systemctl`). The old process keeps heartbeating a stale copy of a resource under the same name, and the auth server can end up not surfacing your freshly-registered same-named resource in `tsh ls` / `tsh db ls` even though your new agent's own log says "started successfully."
Symptom: a database or node you just configured doesn't show up, no errors anywhere obvious. Before chasing TLS or RBAC theories, check for a second process:
```bash
ps aux | grep '[t]eleport start'
```
If found and clearly orphaned (no matching systemd unit, config path that no longer exists), confirm with the user, then stop it (`kill -TERM <pid>`, escalate to `-KILL` only if it doesn't exit) and restart the real service — the resource should appear cleanly.

View File

@@ -0,0 +1,56 @@
---
name: topology-diagram
description: Build or update the MBU Group SSH/database access topology diagram (a light-theme HTML+SVG page mirrored into a .drawio.xml file). Use when asked to visualize, draw, update, or "perbarui" the infra access topology, network diagram, or Teleport topology — including adding a new provider/host/database, correcting IPs or specs, or fixing arrow/layout issues. Always re-verify every value against live infra before writing it; never hand-type an IP, spec, or resource name from memory.
---
# Topology Diagram
Two files always ship together and must stay in sync:
- `topologi-ssh-teleport.html` — the primary, richly-styled artifact (what people actually read)
- `topologi-ssh-teleport.drawio.xml` — an editable mirror for the team, generated from the same coordinates/content, one visual generation behind on cosmetic-only details (see "drawio limitations" below)
Ask the user where these live in their vault/Downloads if unknown — don't assume a path.
## Golden rule: verify before you draw
Never write an IP, spec, capacity number, or resource name from memory or from the diagram's own prior state. Before touching the file, pull fresh data from the actual source:
- Droplets/VMs: `doctl compute droplet list --format Name,PublicIPv4,PrivateIPv4,Memory,VCPUs,Disk,Region,Status`
- Managed databases: `doctl databases list -o json` (get `storage_size_mib`, `size` slug, `num_nodes` — the list view alone omits storage)
- AWS compute: `aws ec2 describe-instances` (loop every region if the primary region search comes up empty — check with `aws ec2 describe-regions`)
- AWS databases: `aws rds describe-db-instances`
- Teleport's own view: `tsh ls`, `tsh db ls -f json`, `tsh app ls` — this is what's *actually* registered, which can differ from what a droplet list implies
- Cluster roles/config: `tctl get roles --format=json` (see teleport-onboard skill for where tctl actually runs)
If a resource shows up in the diagram's current text but you can't re-confirm it from a live source, don't silently trust it — flag it as unverified in the diagram itself (dashed border + a short "perlu verifikasi manual" note) rather than deleting it or leaving it looking equally confident as verified data. This has caught real bugs: a database name that looked like a duplicate turned out to be a distinct self-hosted DB reachable only via `tsh db ls`, not `doctl`.
## Design system (HTML)
Everything is driven by CSS custom properties on `:root` — change the palette in one place:
```css
--bg, --surface, --line, --line-soft, --text, --text-dim, --text-faint /* neutrals */
--accent /* the single ingress chokepoint (e.g. the proxy/gateway) — spend this color deliberately, once */
--user, --core, --agent, --do, --db, --wire, --legacy, --aws /* one hue per semantic category, reused everywhere: node icon, node border stripe, matching edges, zone eyebrow labels */
```
Pick a light or dark ground deliberately; light was chosen last for readability and print/PDF friendliness. Typography: a display sans (Archivo) for titles, a mono face (JetBrains Mono) for every IP/port/command/capacity number — the mono choice is not decorative, it's because the subject is literally terminal/IP data.
**Node card pattern** (repeated ~30×): a `<path>` (not `<rect>`) drawing a rectangle rounded *only on the right* (left corners stay square, matching the left accent stripe):
```
M{x},{y} L{x2-r},{y} A{r},{r} 0 0 1 {x2},{y+r} L{x2},{y2-r} A{r},{r} 0 0 1 {x2-r},{y2} L{x},{y2} Z
```
plus a thin `<rect>` (`width:3-4, rx:0`) at the same x/y as the left accent stripe in the category color, plus a `<foreignObject>` holding real HTML (icon svg + `<b>` title + `.n-meta` lines, one of which is usually `.faint` for the least-important line). foreignObject is what makes rich HTML-in-SVG possible at all — don't try to hand-roll the same layout with plain `<text>` elements.
**Zone headers**: a small mono eyebrow label + a thin `<line>` rule spanning the full content width — not a big tinted background band (tried first, looked muddy). **Provider containers** (DigitalOcean / Biznet / AWS): white fill, 2px border in that provider's category color, not a pastel fill.
**Edges**: orthogonal `<path>`s in the category color of what they represent (not what they connect), each with an arrow `<marker>` in `<defs>`. Labels are centered pills: wrap the label text in a flex-centered `<div>` inside a `<foreignObject>` sized/positioned at the edge's true midpoint, styled as `border-radius:999px; background:var(--surface); border:1px solid var(--line);` — this reads as a chip floating on the line, not text mashed into the background.
**Layout discipline**: when adding a new column (a new provider, a new agent lane), recenter it under its own container and re-run the numbers for every sibling that shares a header divider or trunk edge — don't leave one column narrower/off-center while others got the treatment. Widen the `viewBox` and extend header `<line>` rules to the new right margin; don't leave them stopping short.
**Print/PDF**: add an `@media print` block (`@page{size:landscape}`, `.layout{display:block}`, `page-break-after` on the diagram frame) so a *browser's own* print dialog doesn't crush a wide multi-column layout into an unreadable thumbnail. But if the user wants a PDF that matches the *on-screen* view exactly (not a print-reflowed one), that's a different tool — see the `html-to-pdf` skill instead of fighting the print dialog further.
## drawio mirror
drawio's `mxCell` style language can't do per-corner rounding, arbitrary SVG icon paths, or CSS-variable-driven theming. Don't hand-edit 30+ `mxCell` blocks for a structural change — write a small Python generator with helper functions (`node()`, `container()`, `edge()`, `eyebrow()`) that takes the *same* coordinate/color/text data as the HTML and emits the XML. This is far less error-prone than manual edits and keeps the two files derivable from one source of truth even though they're not literally the same code.
Known, accepted gaps versus the HTML (state these to the user rather than silently mismatching): symmetric `arcSize` rounding on all four corners (not right-only), no custom icons (title + category color + left stripe carries the scanability instead), edge labels approximated via a child "edgeLabel" vertex with `rounded=1;arcSize=100` for the pill look.
After any edit to either file: validate XML with `python3 -c "import xml.etree.ElementTree as ET; ET.parse('file.xml')"`, check `<foreignObject>`/`<g>` tag balance in the HTML, then `open <path>` to eyeball it in a browser before calling it done.

View File

@@ -0,0 +1,7 @@
{
"name": "openbao-session",
"version": "0.1.3",
"description": "Provisions a short-lived OpenBao session token for interactive agent sessions.",
"author": { "name": "Senkensha" },
"hooks": "./hooks/claude-codex-hooks.json"
}

View File

@@ -0,0 +1,20 @@
{
"name": "openbao-session",
"version": "0.1.3+codex.20260901113700",
"description": "Provisions a short-lived OpenBao session token for interactive agent sessions.",
"author": {
"name": "Local developer"
},
"hooks": "./hooks/claude-codex-hooks.json",
"interface": {
"displayName": "OpenBao Session",
"shortDescription": "Automatic OpenBao session token provisioning.",
"longDescription": "At session start, opens a loopback SSH tunnel to OpenBao and injects a short-lived, scoped session token so the agent can read only the secrets its policy permits.",
"developerName": "Local developer",
"category": "Security",
"capabilities": [
"Lifecycle hooks"
],
"defaultPrompt": "Help me use OpenBao Session."
}
}

View File

@@ -0,0 +1,112 @@
# openbao-session
Hook `SessionStart` untuk Claude Code dan Codex yang menyuntikkan **token OpenBao berumur pendek** ke lingkungan sesi agent. Dengan ini agent tidak perlu memegang credential statis — setiap sesi mendapat token baru yang scoped dan kadaluarsa sendiri.
## Cara kerja
Saat sesi mulai, `hooks/bao-session.sh`:
1. Memastikan **SSH tunnel loopback** ke listener VPC privat OpenBao hidup (`127.0.0.1:8200 → 10.104.0.12:8200` lewat jumphost `mbu-backup-jumphost`).
2. Mengambil CA cert OpenBao (`/opt/openbao/tls/tls.crt`) dan menyimpannya lokal.
3. Membaca **bootstrap credential** dari secrets store lokal (`manage.py get openbao bootstrap_token`).
4. **Mint token sesi** `claude-session` (TTL `4h`, renewable) memakai bootstrap credential sebagai caller; bila `OPENBAO_SESSION_ENTITY` diset, token dikaitkan ke entity pemakai (`-entity-alias`) sehingga `{{identity.entity.id}}` resolve dan sesi dapat membaca `humans/self`.
5. Menyuntikkan `OPENBAO_TOKEN`, `BAO_ADDR`, `BAO_CACERT`, `BAO_TLS_SERVER_NAME` ke sesi:
| Host | Mekanisme injeksi |
|---|---|
| **Claude Code** | Menulis `export OPENBAO_TOKEN=...` ke **`$CLAUDE_ENV_FILE`** — file yang di-source Claude Code ke setiap command Bash sesi itu. |
| **Codex / host lain** | Hook Codex **tidak bisa persist env** (proses hook terpisah). Skrip menulis session env file (mode 600) dan memberitahu agent lewat `additionalContext` untuk `source` file itu — **nilai token tidak pernah masuk konteks model**. |
Token sesi cuma bisa membaca secret yang diizinkan policy `claude-session` (bukan semua). Semua akses tercatat di audit OpenBao.
## Prasyarat sekali-jalan (platform admin)
Dengan token admin di jumphost (lihat [OpenBao Production Operations and Recovery](../../../Areas/DevOps/Notes/Runbooks/OpenBao%20Production%20Operations%20and%20Recovery.md)):
```bash
export BAO_ADDR=https://10.104.0.12:8200
export BAO_CACERT=/opt/openbao/tls/tls.crt
export BAO_TOKEN=<token admin Anda> # jangan di-paste ke chat
# 1. Policy bootstrap: bisa membuat token, TIDAK bisa membaca secret.
# Wajib menyertakan auth/token/create/* agar pembuatan token via -role (entity-alias) diizinkan.
cat > /tmp/openbao-session-creator.hcl <<'EOF'
path "auth/token/create" {
capabilities = ["create", "update", "sudo"]
}
path "auth/token/create/*" {
capabilities = ["create", "update", "sudo"]
}
path "auth/token/renew-self" { capabilities = ["update"] }
path "auth/token/revoke-self" { capabilities = ["update"] }
path "auth/token/lookup-self" { capabilities = ["read"] }
EOF
bao policy write openbao-session-creator /tmp/openbao-session-creator.hcl
# 2. Policy sesi: baca-only untuk secret yang memang di-assign ke agent.
# humans/self otomatis ter-scope ke entity pemakai via {{identity.entity.id}}
cat > /tmp/claude-session.hcl <<'EOF'
# Refine daftar path di sini sesuai owner/consumer assignment.
path "shared/data/*" { capabilities = ["read", "list"] }
path "shared/metadata/*" { capabilities = ["read", "list"] }
path "humans/data/{{identity.entity.id}}/*" { capabilities = ["read", "list"] }
path "humans/metadata/{{identity.entity.id}}/*" { capabilities = ["read", "list"] }
EOF
bao policy write claude-session /tmp/claude-session.hcl
# 3. Token roles (wajib utk -entity-alias). Perluas allowed_entity_aliases sesuai tim.
bao write auth/token/roles/openbao-session \
allowed_policies=claude-session \
allowed_entity_aliases=adnan,rizal \
ttl=4h renewable=true
# 4. Issue bootstrap token (TTL panjang, renewable) dan simpan NILAINYA ke secrets store LOKAL:
bao token create -policy=openbao-session-creator -ttl=720h -renewable \
-display-name=openbao-session-bootstrap -format=json
# → ambil auth.client_token, lalu di Mac Anda:
python3 ~/.config/devops-secrets/manage.py set openbao bootstrap_token --description "OpenBao session bootstrap (creator)"
```
> Bootstrap credential ini **kuat** (bisa mint token policy apa pun) tapi **tidak bisa membaca nilai secret**.
> Karena itu simpan hanya di secrets store lokal (`~/.config/devops-secrets`, mode 700/600), jangan di vault/repo/chat.
> Upgrade ke AppRole atau OIDC saat identity provider disetujui.
## Instalasi
### Claude Code
- Daftarkan marketplace (jika belum): `claude plugin marketplace add infra-plugins https://git.senkensha.space/senkensha/infra-plugins.git`
- Pasang: `claude plugin install openbao-session@infra-plugins`
- Konfirmasi hook aktif: buka sesi baru, lihat status message "Provisioning OpenBao session token...", lalu `echo ${OPENBAO_TOKEN:+SET}`.
### Codex
- `codex plugin install openbao-session@infra-plugins` (marketplace sudah terdaftar sebagai `infra-plugins`).
## Konfigurasi (opsional, via env)
| Variable | Default | Keterangan |
|---|---|---|
| `OPENBAO_SESSION_CONF_DIR` | `~/.config/openbao` | dir cache CA cert + binary bao |
| `OPENBAO_SESSION_LOCAL_PORT` | `8200` | port tunnel lokal |
| `OPENBAO_SESSION_VPC_ADDR` | `10.104.0.12` | alamat VPC listener OpenBao |
| `OPENBAO_SESSION_DROPLET` | `mbu-backup-jumphost` | droplet jumphost |
| `OPENBAO_SESSION_HOST` | *(kosong)* | public IP jumphost eksplisit (default: resolve via doctl) |
| `OPENBAO_SESSION_SSH_KEY` | `~/.ssh/id_ed25519` | key SSH ke jumphost |
| `OPENBAO_SESSION_POLICY` | `claude-session` | policy token sesi |
| `OPENBAO_SESSION_TTL` | `4h` | umur token sesi |
| `OPENBAO_SESSION_ENTITY` | *(kosong)* | nama entity OpenBao pemakai (mis. `adnan`, `rizal`) → token sesi diikat ke entity (aktifkan `humans/self`) |
| `OPENBAO_SESSION_ROLE` | *(kosong)* | token role utk sesi (mis. `openbao-session`). Wajib diisi jika `OPENBAO_SESSION_ENTITY` diset (`-entity-alias` hanya bekerja dgn `-role`) |
| `OPENBAO_SESSION_BAO_VER` | `2.6.2` | versi binary bao (auto-download jika belum ada) |
| `OPENBAO_SESSION_SECRETS_HELPER` | `~/.config/devops-secrets/manage.py` | helper baca bootstrap |
| `OPENBAO_SESSION_BOOTSTRAP_TOOL` / `_KEY` | `openbao` / `bootstrap_token` | lokasi bootstrap di secrets store |
## Keamanan
- Nilai secret tidak pernah ditulis ke repo, chat, atau log; token sesi mengalir lewat `$CLAUDE_ENV_FILE` (Claude) atau session env file mode 600 (Codex), bukan argv dan bukan ke konteks model.
- Token sesi **short-lived** (`4h`, renewable) dan **scoped** ke policy `claude-session` saja.
- `humans/self` hanya terbaca lewat token yang **terikat entity pemakai** (`{{identity.entity.id}}`) — sesi orang lain tidak bisa membaca ruang pribadi Anda.
- Bootstrap credential tidak bisa membaca secret, tapi bisa mint token → rotasi bila dicurigai bocor: buat bootstrap baru, `manage.py set`, revoke yang lama (`bao token revoke <accessor>`).
- Tunnel hanya bind `127.0.0.1`; jangan pernah expose port publik atau pakai TLS skip-verify.
## Related
- [OpenBao Secret Taxonomy and Migration](../../../Areas/DevOps/Credentials/OpenBao%20Secret%20Taxonomy%20and%20Migration.md)
- [OpenBao Production Operations and Recovery](../../../Areas/DevOps/Notes/Runbooks/OpenBao%20Production%20Operations%20and%20Recovery.md)

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# openbao-session — SessionStart hook.
#
# Provisions a short-lived, policy-scoped OpenBao token for this interactive
# agent session. At session start it:
# 1. ensures the loopback SSH tunnel to the OpenBao private VPC listener is up,
# 2. caches the OpenBao TLS trust anchor locally,
# 3. mints a short-lived session token from a bootstrap credential,
# 4. makes it available to the session:
# - Claude Code: writes `export OPENBAO_TOKEN=...` (and BAO_ADDR, BAO_CACERT,
# BAO_TLS_SERVER_NAME) into $CLAUDE_ENV_FILE, which Claude Code sources
# into every subsequent Bash command of the session.
# - Other hosts (e.g. Codex), which cannot persist env from a hook: writes a
# session-scoped env file (mode 600) and points the agent to it via
# hookSpecificOutput.additionalContext — never the token value itself.
#
# Output: a single valid JSON object on stdout. On failure it still emits a
# benign `{"continue": true, "suppressOutput": true}` and exits 0 so the session
# starts cleanly; diagnostics go to stderr.
#
# One-time admin prerequisites and security notes: see README.md in this plugin.
set -euo pipefail
log() { echo "openbao-session: $*" >&2; }
# ---- configuration (env-overridable, defaults match the current production setup)
CONF_DIR="${OPENBAO_SESSION_CONF_DIR:-$HOME/.config/openbao}"
BIN_DIR="$CONF_DIR/bin"
LOCAL_PORT="${OPENBAO_SESSION_LOCAL_PORT:-8200}"
VPC_ADDR="${OPENBAO_SESSION_VPC_ADDR:-10.104.0.12}"
DROPLET="${OPENBAO_SESSION_DROPLET:-mbu-backup-jumphost}"
SSH_KEY="${OPENBAO_SESSION_SSH_KEY:-$HOME/.ssh/id_ed25519}"
HOST="${OPENBAO_SESSION_HOST:-}" # optional explicit public IP
TLS_NAME="${OPENBAO_SESSION_TLS_NAME:-mbu-backup-jumphost}"
BAO_VER="${OPENBAO_SESSION_BAO_VER:-2.6.2}"
SESSION_POLICY="${OPENBAO_SESSION_POLICY:-claude-session}"
SESSION_TTL="${OPENBAO_SESSION_TTL:-4h}"
SESSION_ENTITY="${OPENBAO_SESSION_ENTITY:-}" # OpenBao entity name; ties the session token to the user (enables humans/self via {{identity.entity.id}})
SESSION_ROLE="${OPENBAO_SESSION_ROLE:-}" # token role (required when SESSION_ENTITY is set; entity-alias only works with a role)
SECRETS_HELPER="${OPENBAO_SESSION_SECRETS_HELPER:-$HOME/.config/devops-secrets/manage.py}"
BOOTSTRAP_TOOL="${OPENBAO_SESSION_BOOTSTRAP_TOOL:-openbao}"
BOOTSTRAP_KEY="${OPENBAO_SESSION_BOOTSTRAP_KEY:-bootstrap_token}"
CACERT="$CONF_DIR/tls.crt"
BAO_BIN=""
BAO_ADDR_URL="https://127.0.0.1:${LOCAL_PORT}"
SESSION_TOKEN=""
benign_json() { printf '%s\n' '{"continue": true, "suppressOutput": true}'; }
ensure_bao() {
if command -v bao >/dev/null 2>&1; then BAO_BIN="$(command -v bao)"; return 0; fi
if [ -x "$BIN_DIR/bao" ]; then BAO_BIN="$BIN_DIR/bao"; return 0; fi
mkdir -p "$BIN_DIR"
local os arch asset url
os="$(uname -s)"; arch="$(uname -m)"
case "$os:$arch" in
Darwin:arm64) asset="openbao_${BAO_VER}_darwin_arm64.tar.gz" ;;
Darwin:x86_64|Darwin:amd64) asset="openbao_${BAO_VER}_darwin_amd64.tar.gz" ;;
Linux:arm64) asset="openbao_${BAO_VER}_linux_arm64.tar.gz" ;;
Linux:x86_64|Linux:amd64) asset="openbao_${BAO_VER}_linux_amd64.tar.gz" ;;
*) log "unsupported platform: $os/$arch"; return 1 ;;
esac
url="https://github.com/openbao/openbao/releases/download/v${BAO_VER}/${asset}"
curl -fsSL -o "$CONF_DIR/bao.tar.gz" "$url" || { log "failed to download $asset"; return 1; }
tar -xzf "$CONF_DIR/bao.tar.gz" -C "$BIN_DIR" bao || { log "failed to extract bao"; return 1; }
chmod +x "$BIN_DIR/bao"
rm -f "$CONF_DIR/bao.tar.gz"
BAO_BIN="$BIN_DIR/bao"
}
resolve_host() {
[ -n "$HOST" ] && return 0
command -v doctl >/dev/null 2>&1 || { log "doctl missing and OPENBAO_SESSION_HOST unset"; return 1; }
HOST="$(doctl compute droplet get "$DROPLET" --format PublicIPv4 --no-header 2>/dev/null | tr -d '[:space:]')"
[[ "$HOST" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || { log "cannot resolve $DROPLET public IPv4"; return 1; }
}
ensure_tunnel() {
if nc -z 127.0.0.1 "$LOCAL_PORT" >/dev/null 2>&1; then return 0; fi
[ -r "$SSH_KEY" ] || { log "SSH key not readable: $SSH_KEY"; return 1; }
resolve_host || return 1
ssh -f -N -i "$SSH_KEY" -o BatchMode=yes -o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=accept-new \
-L "127.0.0.1:${LOCAL_PORT}:${VPC_ADDR}:8200" "root@${HOST}" || return 1
sleep 1
nc -z 127.0.0.1 "$LOCAL_PORT" >/dev/null 2>&1 || { log "tunnel did not come up"; return 1; }
}
ensure_cacert() {
[ -s "$CACERT" ] && return 0
resolve_host || return 1
mkdir -p "$CONF_DIR"
scp -q -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
"root@${HOST}:/opt/openbao/tls/tls.crt" "$CACERT" || { log "failed to fetch CA cert"; return 1; }
}
read_bootstrap() {
python3 "$SECRETS_HELPER" get "$BOOTSTRAP_TOOL" "$BOOTSTRAP_KEY" 2>/dev/null \
|| { log "bootstrap credential not found: $BOOTSTRAP_TOOL/$BOOTSTRAP_KEY (see README, one-time admin setup)"; return 1; }
}
mint_session_token() {
local bootstrap json
local -a create_args=()
bootstrap="$(read_bootstrap)" || return 1
if [ -n "$SESSION_ROLE" ]; then
# token role governs policy + allows entity-alias (entity-alias needs a role)
create_args+=(-role="$SESSION_ROLE")
[ -n "$SESSION_ENTITY" ] && create_args+=(-entity-alias="$SESSION_ENTITY")
else
create_args+=(-policy="$SESSION_POLICY")
fi
json="$(BAO_ADDR="$BAO_ADDR_URL" BAO_CACERT="$CACERT" BAO_TLS_SERVER_NAME="$TLS_NAME" BAO_TOKEN="$bootstrap" \
"$BAO_BIN" token create "${create_args[@]}" -ttl="$SESSION_TTL" -renewable \
-display-name="openbao-session-$(hostname)" -format=json)" || { log "token create failed"; return 1; }
SESSION_TOKEN="$(printf '%s' "$json" | jq -r '.auth.client_token // empty')"
[ -n "$SESSION_TOKEN" ] || { log "no client_token in token create response"; return 1; }
}
session_id_from_stdin() {
local input
input="$(cat 2>/dev/null || true)"
[ -n "$input" ] && command -v jq >/dev/null 2>&1 \
&& printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null
}
write_env_exports() {
printf 'export OPENBAO_TOKEN=%q\n' "$SESSION_TOKEN"
printf 'export BAO_ADDR=%q\n' "$BAO_ADDR_URL"
printf 'export BAO_CACERT=%q\n' "$CACERT"
printf 'export BAO_TLS_SERVER_NAME=%q\n' "$TLS_NAME"
}
inject_env() {
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
# Claude Code: persist exports for every subsequent Bash command this session.
write_env_exports >> "$CLAUDE_ENV_FILE" || { log "failed to write CLAUDE_ENV_FILE"; return 1; }
benign_json
return 0
fi
# Other hosts (Codex etc.): hooks cannot persist env; write a session-scoped
# env file and point the agent to it. Never put the token value in context.
local sid envfile
sid="$(session_id_from_stdin)"
envfile="${TMPDIR:-/tmp}/openbao-session.env.${sid:-$$}"
write_env_exports > "$envfile" || return 1
chmod 600 "$envfile"
printf '{"continue":true,"suppressOutput":true,"hookSpecificOutput":{"additionalContext":"OpenBao session token ready. In a Bash tool, load it without printing the value: source %s"}}\n' "$envfile"
}
run() {
ensure_bao || return 1
ensure_tunnel || return 1
ensure_cacert || return 1
mint_session_token || return 1
inject_env || return 1
}
main() {
local out rc=0
out="$(run)" || rc=1
if [ "$rc" -eq 0 ]; then
printf '%s\n' "$out"
else
log "provisioning failed; session continues without an OpenBao session token"
benign_json
fi
exit 0
}
main "$@"

View File

@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/bao-session.sh\"",
"timeout": 30,
"statusMessage": "Provisioning OpenBao session token..."
}
]
}
]
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "shared-vault-sync",
"version": "0.1.2",
"description": "Safely pulls the shared vault at interactive session start.",
"author": { "name": "Senkensha" },
"hooks": "./hooks/claude-codex-hooks.json"
}

View File

@@ -0,0 +1,20 @@
{
"name": "shared-vault-sync",
"version": "0.1.2+codex.20260901103344",
"description": "Safely pulls the shared vault at interactive session start.",
"author": {
"name": "Local developer"
},
"hooks": "./hooks/claude-codex-hooks.json",
"interface": {
"displayName": "Shared Vault Sync",
"shortDescription": "Safely pull the shared vault at session start.",
"longDescription": "Fast-forwards a clean shared vault at session start without blocking the agent when the network is unavailable.",
"developerName": "Local developer",
"category": "Productivity",
"capabilities": [
"Lifecycle hooks"
],
"defaultPrompt": "Synchronize the shared vault."
}
}

View File

@@ -0,0 +1,39 @@
# shared-vault-sync
Pulls a clean shared vault at `SessionStart` without blocking Claude Code or Codex when the network is unavailable.
Target detection, in order:
1. `SHARED_VAULT_DIR` — explicit override (e.g. the `devops-shared-vault` clone path).
2. `$PWD` — when the session starts inside the shared vault itself.
3. `$PWD/Areas/DevOps` — the nested shared subrepo inside the personal vault.
A directory is treated as the shared vault only if its `AGENTS.md` contains the marker `This is the shared persistent vault` and it is inside a git work tree.
The hook skips a dirty worktree and uses `git pull --ff-only`; it never rebases, merges, or pushes.
## Install
Install `shared-vault-sync` from the `infra-plugins` marketplace, then open a new session to activate its `SessionStart` hook.
## Auto-push (launchd)
`scripts/push-shared-vault.sh` mengirim commit lokal ke remote secara **commit-driven**:
hanya jika worktree bersih **dan** branch lokal di depan `origin` (fetch dulu). Tidak pernah
rebase, force-push, atau resolve conflict; no-op saat offline atau tidak ada yang perlu di-push.
Setup (macOS launchd, interval 5 menit):
```bash
# 1. pastikan script tersedia di plugin (repo infra-plugins)
# 2. buat plist ~/Library/LaunchAgents/com.mbugroup.shared-vault-push.plist:
# - ProgramArguments: /bin/bash <infra-plugins>/plugins/shared-vault-sync/scripts/push-shared-vault.sh
# - EnvironmentVariables: SHARED_VAULT_DIR=<path ke clone shared vault>
# - StartInterval: 300
# 3. muat:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mbugroup.shared-vault-push.plist
launchctl kickstart -k gui/$(id -u)/com.mbugroup.shared-vault-push
```
Log push: `$PUSH_SHARED_VAULT_LOG` (default `/tmp/shared-vault-push.log`), hanya timestamp + jumlah commit.
Hook pull tetap menangani sinkronisasi masuk; agent ini menangani arah keluar. Push tetap commit-driven:
agent tidak pernah membuat komit, hanya mengirim komit yang sudah Anda buat di `Areas/DevOps`.

View File

@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pull-shared-vault.sh\"",
"timeout": 15,
"statusMessage": "Synchronizing shared vault..."
}
]
}
]
}
}

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Pull only the designated shared vault; never block a new agent session.
set -u
emit() {
printf '{"continue":true,"suppressOutput":true,"hookSpecificOutput":{"additionalContext":"%s"}}\n' "$1"
}
is_shared_vault() {
[ -f "$1/AGENTS.md" ] \
&& grep -Fq 'This is the shared persistent vault' "$1/AGENTS.md" \
&& git -C "$1" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
detect_vault() {
# explicit override wins
if [ -n "${SHARED_VAULT_DIR:-}" ]; then
is_shared_vault "$SHARED_VAULT_DIR" && { printf '%s' "$SHARED_VAULT_DIR"; return 0; }
return 1
fi
# session started inside the shared vault itself
if is_shared_vault "${PWD:-}"; then printf '%s' "${PWD:-}"; return 0; fi
# nested shared subrepo relative to the session dir (e.g. personal vault's Areas/DevOps)
if is_shared_vault "${PWD:-}/Areas/DevOps"; then printf '%s' "${PWD:-}/Areas/DevOps"; return 0; fi
return 1
}
vault_dir="$(detect_vault)" || {
emit 'Shared vault sync skipped: no shared vault detected (set SHARED_VAULT_DIR, or start the session from the shared vault or its Areas/DevOps subrepo).'
exit 0
}
if [ -n "$(git -C "$vault_dir" status --porcelain)" ]; then
emit 'Shared vault sync skipped: the worktree has local changes.'
exit 0
fi
if GIT_TERMINAL_PROMPT=0 git -C "$vault_dir" pull --ff-only --quiet; then
emit 'Shared vault sync completed.'
else
emit 'Shared vault sync failed; run git pull --ff-only from the vault when it is safe to do so.'
fi

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# push-shared-vault — commit-driven auto-push for the shared vault.
#
# Safe by design:
# - pushes only when the worktree is clean (no uncommitted changes),
# - pushes only when the local branch is ahead of its remote (fetch first),
# - never rebases, force-pushes, or resolves conflicts,
# - silently no-ops when offline or when there is nothing to push.
#
# Intended to run from launchd or cron at an interval (e.g. every 5 minutes).
# Requires: SHARED_VAULT_DIR=<path to the shared vault clone>.
set -u
VAULT_DIR="${SHARED_VAULT_DIR:-}"
LOG="${PUSH_SHARED_VAULT_LOG:-/tmp/shared-vault-push.log}"
[ -n "$VAULT_DIR" ] || exit 0
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; }
git -C "$VAULT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
[ -z "$(git -C "$VAULT_DIR" status --porcelain)" ] || exit 0
git -C "$VAULT_DIR" fetch --quiet origin 2>/dev/null || exit 0
branch="$(git -C "$VAULT_DIR" rev-parse --abbrev-ref HEAD)" || exit 0
ahead="$(git -C "$VAULT_DIR" rev-list --count "origin/$branch..HEAD" 2>/dev/null || echo 0)"
[ "${ahead:-0}" -gt 0 ] || exit 0
if GIT_TERMINAL_PROMPT=0 git -C "$VAULT_DIR" push origin "$branch"; then
log "pushed $ahead commit(s) to origin/$branch"
fi

View File

@@ -1,7 +1,7 @@
{
"name": "taiga-auto-sync",
"version": "0.1.0",
"description": "Injects IT Infra & Ops Taiga tracking rules on every Claude prompt.",
"version": "0.1.0+codex.20260829131410",
"description": "Automatically tracks material IT Infra & Ops work and guards Taiga writes.",
"author": { "name": "Senkensha" },
"hooks": "./hooks/claude-codex-hooks.json"
}

View File

@@ -1,18 +1,22 @@
{
"name": "taiga-auto-sync",
"version": "0.1.0",
"description": "Injects Taiga tracking rules on every Codex prompt.",
"version": "0.1.0+codex.20260829131410",
"description": "Automatically tracks material IT Infra & Ops work and guards Taiga writes.",
"author": {
"name": "Local developer"
},
"hooks": "./hooks/claude-codex-hooks.json",
"skills": "./skills/",
"interface": {
"displayName": "Taiga Auto Sync",
"shortDescription": "Automatic Taiga tracking reminder.",
"longDescription": "Injects the IT Infra & Ops Taiga contract before each prompt.",
"shortDescription": "Automatic Infra/Ops Taiga tracking.",
"longDescription": "Tracks material IT Infra & Ops work by context and validates Taiga writes.",
"developerName": "Local developer",
"category": "Productivity",
"capabilities": ["Lifecycle hooks"],
"capabilities": [
"Skills",
"Lifecycle hooks"
],
"defaultPrompt": "Help me use Taiga Auto Sync."
}
}

View File

@@ -1,11 +1,12 @@
{
"hooks": {
"UserPromptSubmit": [{
"PreToolUse": [{
"matcher": "^mcp__taiga__(createUserStory|createTask|createIssue|batchCreateUserStories|batchCreateTasks|batchCreateIssues)$",
"hooks": [{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/taiga-reminder.sh\"",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/taiga-write-guard.py\"",
"timeout": 3,
"statusMessage": "Checking Taiga tracking..."
"statusMessage": "Checking Taiga item metadata..."
}]
}]
}

View File

@@ -1,4 +0,0 @@
#!/bin/sh
cat <<'EOF'
TAIGA AUTO-SYNC: For material IT Infra & Ops work, create or update the matching Taiga item now through the scoped launcher. Do not track read-only/no-op work. A new User Story must use the Wiki description template; preserve its state file for progress, blocked, and verified done updates.
EOF

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Reject Taiga item creation that is missing the required tag contract.
Only checks tags. Owner and sprint are not creation-time fields on the
Taiga MCP server's create tools (createUserStory/createTask/createIssue and
their batch variants) -- they are set afterwards via assignIssue,
assignUserStoryToSprint, or addIssueToSprint. Tasks have no assignee/sprint
tool at all, so their owner and sprint are tracked through the parent
User Story instead.
"""
import json
import sys
AREAS = {
"infra", "network", "cloud", "security", "backup", "monitoring",
"access", "automation", "support", "vendor",
}
# Batch-create tools nest each item's tags under one of these list keys
# instead of exposing a top-level "tags" array.
BATCH_LIST_KEYS = ("userStories", "tasks", "issues")
def missing_tag_fields(tags):
tags = set(tags or [])
missing = []
if not tags.intersection(AREAS):
missing.append("area tag")
if not any(tag.startswith("source:") for tag in tags):
missing.append("source tag")
if not any(tag.startswith(("repo:", "system:")) for tag in tags):
missing.append("target tag")
return missing
def missing_fields(payload):
item = payload.get("tool_input", {})
for key in BATCH_LIST_KEYS:
entries = item.get(key)
if isinstance(entries, list):
problems = []
for index, entry in enumerate(entries):
missing = missing_tag_fields(entry.get("tags"))
if missing:
label = entry.get("subject") or f"item {index}"
problems.append(f"{label!r}: missing " + ", ".join(missing))
return problems
return missing_tag_fields(item.get("tags"))
def result(payload):
missing = missing_fields(payload)
if not missing:
return None
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Taiga item is missing required tags: " + "; ".join(missing) + ". "
"Apply one area tag, one source:<agent> tag, and one "
"repo:<name>/system:<name> tag. Owner and sprint are not "
"creation-time fields here -- set them right after creation "
"with assignIssue / assignUserStoryToSprint / addIssueToSprint "
"(Tasks have no assignee/sprint tool; track Task ownership and "
"sprint through the parent User Story)."
),
}
}
def main():
if sys.argv[1:] == ["--self-check"]:
ok_tags = ["infra", "source:codex", "repo:brain"]
assert result({"tool_input": {"tags": ok_tags}}) is None
assert result({"tool_input": {"tags": []}}) is not None
ok_batch = {"tool_input": {"issues": [{"subject": "a", "tags": ok_tags}]}}
assert result(ok_batch) is None
bad_batch = {"tool_input": {"issues": [
{"subject": "a", "tags": ok_tags},
{"subject": "b", "tags": []},
]}}
assert result(bad_batch) is not None
print("self-check: ok")
return
output = result(json.load(sys.stdin))
if output:
print(json.dumps(output))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,17 @@
---
name: taiga-operations
description: Automatically track material IT Infrastructure & Operations implementation in Taiga, including deploys, incidents, maintenance, access, backup, monitoring, and infrastructure changes. Do not use for read-only questions, exploration, status explanations, or unrelated product work.
---
# Taiga Operations
Use this workflow automatically when the prompt requests material IT Infra & Ops work. The user does not need to ask for a Taiga update.
1. Do not create or update Taiga for reading, investigation without a change, planning only, no-op work, or secrets.
2. Before creating or activating an item, find the sprint that covers today in WIB. If none exists, create the Monday–Sunday `YYYY-WNN Operations` sprint with `createMilestone`, then use it.
3. Find an existing matching open item before creating a new one. Reuse its local state reference when available.
4. Classify a standalone daily operation as an Issue. Use one User Story only for one outcome with two or more related Tasks; create those Tasks beneath it.
5. Every active User Story, Task, and Issue needs one area tag, one `source:<agent>` tag, and one `repo:<name>` or `system:<name>` tag on the create call itself — the write-guard hook enforces this and blocks creation when it is missing.
6. Owner and sprint are not creation-time fields on this Taiga MCP server. Immediately after creating a User Story or Issue, call `assignUserStoryToSprint` / `assignIssue` / `addIssueToSprint` to set the current sprint and owner before treating the item as active. Tasks have no assignee or sprint tool at all — track Task ownership and sprint through the parent User Story instead.
7. Write all Taiga-facing natural-language text in Bahasa Indonesia: titles, descriptions, comments, summaries, verification, blockers, and handoffs. Preserve code, commands, branch/PR names, repository and service names, domains, tags, statuses, and established technical terms in English.
8. Use the Wiki template for the selected item type. Record only material `start`, `progress`, `blocked`, and verified `done` events. Never put credentials or sensitive evidence in Taiga or local state.