Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,42 @@ on:
branches: [main]
pull_request:
branches: [main]
# Weekly run on main: govulncheck can start failing with no code change
# when a new Go vulnerability is published, so don't wait for a push to
# find out.
schedule:
- cron: "17 5 * * 1"
# Allow on-demand runs from the Actions tab or the API.
workflow_dispatch:

# Least privilege: CI only needs to read the repo.
permissions:
contents: read

jobs:
build:
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
# Actions are pinned to commit SHAs (Dependabot keeps them current);
# a moved or compromised tag can't change what runs here.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
check-latest: true
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
govulncheck ./...
12 changes: 8 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ on:
schedule:
# Re-scan main weekly so new query updates surface dormant issues.
- cron: "37 6 * * 1"
# Allow on-demand scans from the Actions tab or the API.
workflow_dispatch:

jobs:
analyze:
Expand All @@ -20,15 +22,17 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@1190a975f95ce23525efb6a3fc21ea29567c1b52 # v3.38.2
with:
languages: go
queries: security-and-quality
Expand All @@ -37,6 +41,6 @@ jobs:
run: go build ./...

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@1190a975f95ce23525efb6a3fc21ea29567c1b52 # v3.38.2
with:
category: "/language:go"
48 changes: 38 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

[![CI](https://github.com/abinashstack/indmoney-watch/actions/workflows/ci.yml/badge.svg)](https://github.com/abinashstack/indmoney-watch/actions/workflows/ci.yml)
[![CodeQL](https://github.com/abinashstack/indmoney-watch/actions/workflows/codeql.yml/badge.svg)](https://github.com/abinashstack/indmoney-watch/actions/workflows/codeql.yml)
[![Go](https://img.shields.io/badge/Go-1.22%2B-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Platform](https://img.shields.io/badge/Platform-macOS-000?logo=apple&logoColor=white)](https://www.apple.com/macos)
[![Go](https://img.shields.io/badge/Go-1.26%2B-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-000)](#-quickstart)
[![MCP](https://img.shields.io/badge/Protocol-MCP-7c3aed)](https://modelcontextprotocol.io)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Last commit](https://img.shields.io/github/last-commit/abinashstack/indmoney-watch?color=informational)](https://github.com/abinashstack/indmoney-watch/commits/main)
Expand Down Expand Up @@ -41,12 +41,13 @@
| 🪙 **SIP alerts** | Failed installments (sticky transition), upcoming due dates, optional success confirmations |
| 💳 **Credit-card warnings** | Configurable lead time before due dates |
| 🍎 **macOS menu bar** | [SwiftBar](https://github.com/swiftbar/SwiftBar) plugin: live totals, watchlist hover-submenus, one-click actions |
| ⏰ **Background daemon** | `launchd` agent polling Mon–Fri 09:00–16:00 IST, every 10 min by default |
| 🔐 **OAuth 2.1 + PKCE + DCR** | Tokens in macOS Keychain — never on disk |
| ⏰ **Background daemon** | `launchd` agent (macOS) or Task Scheduler task (Windows) polling Mon–Fri 09:00–16:00 IST, every 10 min by default |
| 🪟 **Windows support** | Toast notifications, Task Scheduler poller, DPAPI-encrypted token storage (menu bar is macOS-only) |
| 🔐 **OAuth 2.1 + PKCE + DCR** | Tokens in macOS Keychain, or DPAPI-encrypted on Windows — never in plaintext |

## 🚀 Quickstart

> Requires **Go 1.22+** and **macOS**.
> Requires **Go 1.26+** and **macOS** or **Windows 10 (1809+) / 11**.

```bash
git clone https://github.com/abinashstack/indmoney-watch
Expand All @@ -57,6 +58,32 @@ indw login

`indw login` opens a browser, completes OAuth against `mcp.indmoney.com`, and stashes tokens in your Keychain (service: `indmoney-watch`).

<details>
<summary><b>🪟 Windows</b></summary>

In PowerShell:

```powershell
git clone https://github.com/abinashstack/indmoney-watch
cd indmoney-watch
New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\indw" | Out-Null
go build -o "$env:LOCALAPPDATA\indw\indw.exe" ./cmd/indw
& "$env:LOCALAPPDATA\indw\indw.exe" login
& "$env:LOCALAPPDATA\indw\indw.exe" start # register the background poller
```

(Add `%LOCALAPPDATA%\indw` to your user `PATH` to just type `indw`.)

What's different on Windows:

- **Tokens** are encrypted with DPAPI (bound to your Windows account) in `%AppData%\indmoney-watch\tokens.dpapi`.
- **Alerts** are Windows toast notifications.
- **`indw start`** registers a Task Scheduler task named `indmoney-watch` that runs hidden while you're logged on, including on battery. `indw stop` removes it.
- **Config, state and logs** live in `%AppData%\indmoney-watch\` (`indw paths` shows them).
- **No menu bar app** — SwiftBar is macOS-only. Use `indw status`, `indw watchlist` and `indw sips`.

</details>

<details>
<summary><b>🍎 Add the menu-bar app (SwiftBar)</b></summary>

Expand All @@ -73,7 +100,7 @@ The plugin refreshes every 10 minutes by default (configurable via `menubar.refr
<summary><b>⏰ Run the background alert daemon</b></summary>

```bash
indw start # installs launchd agent — polls Mon–Fri 09:00–16:00 IST every 10 min
indw start # installs launchd agent (Task Scheduler task on Windows) — polls Mon–Fri 09:00–16:00 IST every 10 min
indw stop # uninstalls
indw logs -f # tail the agent log
```
Expand Down Expand Up @@ -175,21 +202,22 @@ menubar:
| `get_indian_stocks_details` / `get_us_stocks_details` | Live LTP + day change (capped at 10 ids/call, transparently chunked) |
| `indian_stocks_sips` / `mf_sips` | SIP status, amounts, next execution |

Polling state lives in `~/.config/indmoney-watch/state.json` (last-seen percentages and alert-firing timestamps for debouncing). OAuth tokens live in the macOS Keychain.
Polling state lives in `~/.config/indmoney-watch/state.json` (`%AppData%\indmoney-watch\state.json` on Windows): last-seen percentages and alert-firing timestamps for debouncing. OAuth tokens live in the macOS Keychain, or in a DPAPI-encrypted file on Windows.

## 🤝 Contributing

PRs welcome. Some directions if you're looking for ideas:

- 🌐 **Cross-platform notifications** — currently uses `osascript` (macOS only). A Linux/Windows backend would open this up beyond Apple-land.
- 🐧 **Linux support** — macOS and Windows are covered; Linux needs a notification backend (`notify-send`), a secret store (libsecret) and a systemd timer.
- 🪟 **Windows tray icon** — the SwiftBar menu is macOS-only; a system-tray equivalent would round out Windows support.
- 🔧 **More tools** — `lookup_ind_keys` for friendly target naming, 52-week high/low alerts, OHLC sparkline rendering in the menu bar.
- ♻️ **Refactor `launchd` setup** — IST scheduling assumes the host TZ; a sleep-loop daemon mode would be more portable.
- ✅ **Tests** — currently zero. The MCP layer is a good target.
- ✅ **Tests** — `go test ./...` runs in CI on macOS and Windows; more coverage of the alert engine is welcome.

When opening a PR:

1. `go build ./...` should succeed cleanly.
2. `go vet ./...` should pass.
2. `go vet ./...` and `go test ./...` should pass.
3. If you change MCP tool calls, mention the tool name + payload in the PR — INDmoney's contract isn't documented anywhere public, so reverse-engineered notes help.

## ⚠️ Caveats
Expand Down
10 changes: 8 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,17 @@ Out of scope:

- The INDmoney MCP server itself (report to INDmoney)
- Vulnerabilities in dependencies — we run `govulncheck` in CI and Dependabot for security updates; if you find something they miss, we still want to know
- Issues that require local root or physical access (the threat model assumes a single-user macOS account)
- Issues that require local root or physical access (the threat model assumes a single-user macOS or Windows account)

## Hardening notes for self-deployers

- OAuth tokens live in macOS Keychain, not in files. The fallback case is also covered in the audit notes.
- OAuth tokens live in macOS Keychain, not in files. **Limitation:** the item is created via `/usr/bin/security`, so its access list trusts that tool, and any process running as your user can read it without a prompt (`security find-generic-password -s indmoney-watch -w`). Keychain storage protects against other users and offline disk access, not against malware running in your own account. If that matters to you, open Keychain Access and restrict the `indmoney-watch` item's access control, or revoke the session from INDmoney after use.
- On Windows, tokens are encrypted with DPAPI (`CryptProtectData`, bound to your Windows account, plus an application-specific entropy value) and stored in `%AppData%\indmoney-watch\tokens.dpapi`. As with the macOS Keychain, this protects against other users and offline disk access, not against malware running as you.
- Windows toast notifications are rendered by Windows PowerShell with text passed through environment variables and XML-escaped — never spliced into the script — and the script itself is passed via `-EncodedCommand`. System binaries (`powershell.exe`, `schtasks.exe`, `conhost.exe`, `rundll32.exe`) are run from `%SystemRoot%\System32` by absolute path.
- Token refreshes are serialised across processes (launchd poller, SwiftBar plugin, CLI) with a lock file in `~/.config/indmoney-watch/`, so concurrent refreshes can't burn a rotated refresh token.
- Errors from the OAuth endpoints only ever report the OAuth error code, never the response body, so tokens and client secrets don't end up in `agent.log`. `agent.log` does contain alert text (holdings, P&L, card dues); it lives in the owner-only (`0700`) config directory.
- Text from INDmoney (names, asset types, error bodies) is stripped of `|`, newlines and control characters before it reaches the SwiftBar plugin output or your terminal, so it can't add clickable `bash=` actions or emit terminal escape sequences.
- System tools (`security`, `osascript`, `launchctl`, `open`, `defaults`, `tail`) are invoked by absolute path, never looked up via `$PATH`.
- The SwiftBar plugin script is installed with `0700` (owner-only) to defend against local plugin-swap attacks. If you're upgrading from an older `indw`, re-run `indw menubar install` to apply the tighter perms.
- macOS notifications are rendered via `osascript` with strings passed through environment variables, not concatenated into the AppleScript body — INDmoney-supplied names cannot inject AppleScript.

Expand Down
144 changes: 144 additions & 0 deletions cmd/indw/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package main

import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"

"github.com/abinashstack/indmoney-watch/internal/config"
)

const (
logTailLines = 200
// maxLogSize triggers a single-generation rotation (agent.log → agent.log.1)
// when the poller writes the log itself, so it can't grow without bound.
maxLogSize = 5 << 20
)

func agentLogPath() (string, error) {
d, err := config.Dir()
if err != nil {
return "", err
}
return filepath.Join(d, "agent.log"), nil
}

// redirectToAgentLog points this process's stdout and stderr at agent.log
// (append), rotating it first if it has grown past maxLogSize.
func redirectToAgentLog() error {
p, err := agentLogPath()
if err != nil {
return err
}
if fi, err := os.Stat(p); err == nil && fi.Size() > maxLogSize {
_ = os.Rename(p, p+".1")
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
fmt.Fprintf(f, "--- run-once %s\n", time.Now().Format(time.RFC3339))
os.Stdout = f
os.Stderr = f
return nil
}

// cmdLogs prints the last lines of agent.log and, with -f, follows it. It's
// implemented in Go rather than shelling out to tail(1) so it behaves the
// same on macOS and Windows.
func cmdLogs(args []string) error {
p, err := agentLogPath()
if err != nil {
return err
}
follow := false
for _, a := range args {
if a == "-f" || a == "--follow" {
follow = true
}
}
f, err := os.Open(p)
if errors.Is(err, os.ErrNotExist) {
fmt.Println("(no log yet — agent hasn't run; use `indw start` to install it, or `indw run-once` to test)")
return nil
}
if err != nil {
return err
}
defer f.Close()

off, err := printTail(os.Stdout, f, logTailLines)
if err != nil || !follow {
return err
}
return followFile(os.Stdout, p, off, time.Second, nil)
}

// printTail writes the last n lines of f to w and returns f's size, i.e. the
// offset to follow from. It reads at most the final 1 MiB.
func printTail(w io.Writer, f *os.File, n int) (int64, error) {
fi, err := f.Stat()
if err != nil {
return 0, err
}
size := fi.Size()
start := size - 1<<20
if start < 0 {
start = 0
}
buf := make([]byte, size-start)
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
return 0, err
}
buf = bytes.TrimRight(buf, "\n")
lines := bytes.Split(buf, []byte("\n"))
if start > 0 && len(lines) > 1 {
lines = lines[1:] // first line is probably partial
}
if len(lines) > n {
lines = lines[len(lines)-n:]
}
bw := bufio.NewWriter(w)
for _, l := range lines {
if len(l) == 0 && len(lines) == 1 {
break
}
bw.Write(l)
bw.WriteByte('\n')
}
return size, bw.Flush()
}

// followFile polls path and copies anything appended after off to w. If the
// file shrinks or is replaced (rotation), it starts again from the top. It
// returns when stop is closed (nil stop: runs until the process exits).
func followFile(w io.Writer, path string, off int64, every time.Duration, stop <-chan struct{}) error {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-stop:
return nil
case <-t.C:
}
f, err := os.Open(path)
if err != nil {
continue // mid-rotation
}
if fi, err := f.Stat(); err == nil {
if fi.Size() < off {
off = 0
}
if fi.Size() > off {
n, _ := io.Copy(w, io.NewSectionReader(f, off, fi.Size()-off))
off += n
}
}
f.Close()
}
}
Loading
Loading